Compare commits

..

1 Commits

Author SHA1 Message Date
didericis-claude 5323fc1b53 feat(cleanup): walk every backend, reap smolmachines orphans too
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 42s
`./cli.py cleanup` previously called only the env-var-selected
backend's `prepare_cleanup` / `cleanup` — so a leftover smolvm
machine + bundle container + bundle network from a crashed
smolmachines bottle would survive a default `docker`-mode cleanup
indefinitely.

Smolmachines now has a real `cleanup` module (alongside
`enumerate.py` from issue #77) that walks:

  - smolvm machines named `claude-bottle-*` (via
    `smolvm machine ls --json`)
  - bundle containers `claude-bottle-sidecars-*`
  - bundle networks `claude-bottle-bundle-*`

Cleanup runs stop+delete on the machines, force-rm on the
containers, network rm on the networks. Each step is best-effort
so a failed rm doesn't block the rest.

`cli.py cleanup` walks every backend in `known_backend_names()`
and runs each backend's `cleanup` after a single y/N prompt that
shows a combined plan.

State dirs (`~/.claude-bottle/state/<slug>/`) are shared layout
with the docker backend, which still owns the orphan-state-dir
bucket. It now consults `enumerate_active_bottles()` for the
cross-backend live identity set so a running smolmachines
bottle's state dir isn't reaped during a cleanup.

Tests: smolmachines cleanup (prepare + cleanup ordering + failure
handling); cross-backend orphan protection on the docker
state-dir check; CLI cmd_cleanup walks both backends, short-
circuits on all-empty, aborts on N. 617 unit tests pass.

End-to-end verified on this host:
  $ smolvm machine ls --json | jq '.[].name'
  "claude-bottle-researcher-m3hxd"
  $ ./cli.py cleanup
  --- smolmachines backend ---
  smolvm machine:  claude-bottle-researcher-m3hxd
  remove all of the above? [y/N]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:21:10 -04:00
268 changed files with 4263 additions and 15541 deletions
-76
View File
@@ -1,76 +0,0 @@
---
name: quality-eval
description: Use when the user asks to objectively evaluate, score, rate, audit, or quality-gate code, codebases, files, pull requests, or snippets using a strict 5-dimension engineering rubric with scores and refactoring steps.
metadata:
short-description: Score code quality with a strict rubric
---
# Quality Eval
## Role
Act as a Staff Software Engineer and automated quality gate. Evaluate code objectively against the rubric below, surface hidden anti-patterns, and provide a mathematical grade with atomic refactoring steps.
## Evaluation Rules
- Evaluate only against the five rubric dimensions.
- Be candid. Do not inflate scores for politeness.
- Avoid generic advice. Every recommendation must name a specific code location, behavior, or pattern and include a concrete improvement direction.
- Inspect the code before scoring. For codebases, read enough representative files, tests, and architecture boundaries to justify the scope.
- When exact line numbers are available, cite them.
- Do not reveal private chain-of-thought. In the required `Chain of Thought Analysis` section, provide a concise, step-by-step audit rationale with observable findings and score justifications.
## Rubric
Score each dimension from 1 to 5 using these anchors:
| Dimension | Score 1 (Fail) | Score 3 (Pass) | Score 5 (Exemplary) |
| :--- | :--- | :--- | :--- |
| **Architecture** | Spaghettified; tight coupling; violated separation of concerns. | Modular but relies on leaky abstractions or mixed domains. | Strict domain isolation; follows SOLID; clear dependency inversion. |
| **Readability** | Cryptic naming; deep nesting (>3 levels); widespread DRY violations. | Idiomatic but features over-complex functions or sparse documentation. | Self-documenting; expressive naming; high cohesion; flat structure. |
| **Resilience** | Swallows errors blindly; lacks contextual logging; fragile to bad input. | Basic try/catch blocks present but lacks granular, typed error handling. | Explicit error boundaries; contextual logging; structured failure modes. |
| **Testability** | Hardcoded dependencies make mocking or isolated testing impossible. | Pure functions are testable, but side-effect heavy logic lacks test hooks. | Decoupled IO; deterministic execution; structured for unit and integration tests. |
| **SecOps** | Hardcoded secrets; O(n^2) bottlenecks; zero input sanitization. | Safe from obvious flaws but lacks deep defensive optimization. | Validated inputs; optimized algorithmic complexity; zero security debt. |
## Scoring Method
1. Determine the evaluated scope and primary language.
2. Identify concrete evidence for each dimension.
3. Assign integer dimension scores from 1 to 5.
4. Compute `composite_score` as the arithmetic mean of the five dimension scores, rounded to one decimal place.
5. Include code snippets only when they make a refactoring step more actionable.
## Required Output
Structure every response into exactly these three Markdown sections:
### 1. Chain of Thought Analysis
Provide a concise step-by-step audit rationale. Name specific files, functions, patterns, anti-patterns, and rubric anchors. Keep it evidence-based and do not include hidden private reasoning.
### 2. Normalized Score Report
```json
{
"evaluation_metadata": {
"target_scope": "string",
"primary_language": "string"
},
"metrics": {
"architecture_and_modularity": 0,
"readability_and_maintainability": 0,
"error_handling_and_resilience": 0,
"testability_and_mocking": 0,
"security_and_performance": 0
},
"composite_score": 0.0
}
```
### 3. Atomic Refactoring Playbook
* **High Priority (To lift Score 1/2 to 3):**
- [ ] Actionable, specific refactoring step with file/line/context reference.
* **Medium Priority (To lift Score 3 to 4/5):**
- [ ] Optimization or architectural pattern implementation step.
@@ -1,3 +0,0 @@
display_name: Quality Eval
short_description: Scores code quality with a strict five-dimension rubric and refactoring playbook.
default_prompt: Evaluate this code objectively using the quality-eval rubric and return the three-section score report.
-65
View File
@@ -1,65 +0,0 @@
# bot-bottle
## What this is
bot-bottle spins up an isolated container for running AI coding agents with a
curated set of skills and env vars. The point is to run agents with broad
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
the container lifecycle and the copying of skills and env vars into it.
## Goals
- Minimize risk of running agents with full permissions
- Allow me to easily spin up agent tasks in parallel
- Create isolated, well defined, easily updated, shareable agents
## Non-goals
- Communicating between agents directly
- Self hosted VMs (v1 uses local Docker containers, not VMs)
- Advanced agent auditing (lean on git history for auditing)
## Repository layout
- `README.md` — short public-facing description.
- `AGENTS.md` — this file, orientation for future agent sessions.
- `.gitignore` — OS junk.
- `bot-bottle.json` — legacy manifest of named agents (env / skills / prompt
per agent), consumed by `cli.py`. See "Manifest" under
"Intended design".
- `docs/README.md` — docs overview; when to write which document.
- `docs/prds/` — product requirement docs (see `docs/prds/README.md` for format).
- `docs/research/` — research notes (see `docs/research/README.md`).
- `docs/decisions/` — decision records (ADR-lite).
## Conventions
- Three kinds of doc, each with its own conventions in-folder; see
`docs/README.md` for when to write which:
- **PRDs** (`docs/prds/`) — one feature per file, numbered
`NNNN-kebab.md`. A `Status:` line tracks lifecycle: Draft → Active
(shipped to `main`) → Superseded/Retargeted. Format in
`docs/prds/README.md`.
- **Research notes** (`docs/research/`) — opinionated investigations;
unnumbered kebab-case, freeform and verdict-first. See
`docs/research/README.md`.
- **Decision records** (`docs/decisions/`) — ADR-lite, numbered
`NNNN-kebab.md`, for policies and non-feature decisions. See
`docs/decisions/README.md`.
- Keep decision rationale self-contained in the repo, not in Gitea
issue threads. Issues are an ephemeral inbox; the durable "why" lives
in a PRD, research note, or decision record.
- Low dependencies by default. The project is Python, stdlib-first (no
runtime pip dependencies in the package itself; the only language
runtime is the Python 3.13 used by the CLI + sidecars). Ask before
adding new tools, runtimes, or package managers.
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
`docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
A `commit-msg` hook in `.githooks/` enforces this. Activate it once per clone
with `git config core.hooksPath .githooks`.
## When you're unsure
Ask. Default to drafting in chat over editing files when the request is ambiguous.
+51
View File
@@ -0,0 +1,51 @@
# claude-bottle
## What this is
claude-bottle spins up an isolated container for running Claude Code with a
curated set of skills and env vars. The point is to run Claude with broad
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
A Python CLI (entry point `cli.py`, package `claude_bottle/`) orchestrates
the container lifecycle and the copying of skills and env vars into it.
## Goals
- Minimize risk of running claude with full permissions
- Allow me to easily spin up agent tasks in parallel
- Create isolated, well defined, easily updated, shareable agents
## Non-goals
- Communicating between agents directly
- Self hosted VMs (v1 uses local Docker containers, not VMs)
- Advanced agent auditing (lean on git history for auditing)
## Repository layout
- `README.md` — short public-facing description.
- `CLAUDE.md` — this file, orientation for future Claude sessions.
- `.gitignore` — OS junk.
- `claude-bottle.json` — manifest of named agents (env / skills / prompt
per agent), consumed by `cli.py`. See "Manifest" under
"Intended design".
- `docs/INDEX.md` — pointer to the research notes.
- `docs/prds/` — product requirement docs.
- `docs/research/` — research notes (empty for now, kept tracked via `.gitkeep`).
## Conventions
- Product requirement docs live in `docs/prds/`.
- Research notes live in `docs/research/`.
- Low dependencies by default. The project is Python, stdlib-first (no
runtime pip dependencies in the package itself; the only language
runtime is the Python 3.13 used by the CLI + sidecars). Ask before
adding new tools, runtimes, or package managers.
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
`docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
A `commit-msg` hook in `.githooks/` enforces this. Activate it once per clone
with `git config core.hooksPath .githooks`.
## When you're unsure
Ask. Default to drafting in chat over editing files when the request is ambiguous.
+6 -6
View File
@@ -1,4 +1,4 @@
# bot-bottle container image. # claude-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,13 +17,13 @@ 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 bot_bottle/ssh.py): the agent # forwarder for the in-container ssh-agent (see claude_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
# against pipelock's bumped TLS without the agent needing local DNS. # against pipelock's bumped TLS without the agent needing local DNS.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils python3 python3-pip python3-venv \ && apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install claude-code globally. Pinned to the version verified in the v1 # Install claude-code globally. Pinned to the version verified in the v1
@@ -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 (bot_bottle/skills.py) drops files into a path owned by the # copier (claude_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,
# `bot_bottle/cli/start.py` runs the container detached and uses `docker exec` # `claude_bottle/cli/start.py` runs the container detached and uses `docker exec`
# to attach a TTY, but this CMD makes `docker run -it bot-bottle-claude` also # to attach a TTY, but this CMD makes `docker run -it claude-bottle` also
# do something useful for ad-hoc debugging. # do something useful for ad-hoc debugging.
CMD ["claude"] CMD ["claude"]
-20
View File
@@ -1,20 +0,0 @@
# 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 python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --no-fund --no-audit @openai/codex@0.136.0 \
&& npm cache clean --force
USER node
WORKDIR /home/node
RUN mkdir -p /home/node/.codex
CMD ["codex"]
+9 -11
View File
@@ -31,13 +31,12 @@
# 9099 egress (mitmproxy, pipelock's upstream — not externally # 9099 egress (mitmproxy, pipelock's upstream — not externally
# addressed by the agent) # addressed by the agent)
# 9418 git-gate (git-daemon) # 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
# 9100 supervise (MCP HTTP) # 9100 supervise (MCP HTTP)
# 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
# bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE. # claude_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
@@ -76,14 +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 bot_bottle/egress_addon_core.py /app/egress_addon_core.py COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py COPY claude_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/supervise.py /app/supervise.py COPY claude_bottle/supervise.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py COPY claude_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py COPY claude_bottle/sidecar_init.py /app/sidecar_init.py
COPY bot_bottle/git_http_backend.py /app/git_http_backend.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
@@ -99,7 +97,7 @@ RUN mkdir -p \
# Documentation only — the compose renderer publishes whichever # Documentation only — the compose renderer publishes whichever
# subset the bottle uses. # subset the bottle uses.
EXPOSE 8888 9099 9418 9420 9100 EXPOSE 8888 9099 9418 9100
# WORKDIR matches Dockerfile.supervise's prior layout so the # WORKDIR matches Dockerfile.supervise's prior layout so the
# in-app same-dir import in supervise_server.py stays deterministic. # in-app same-dir import in supervise_server.py stays deterministic.
+99 -201
View File
@@ -1,10 +1,10 @@
<p align="center"> <p align="center">
<img src="docs/logo.svg" alt="bot-bottle logo" width="140"> <img src="docs/logo.svg" alt="claude-bottle logo" width="140">
</p> </p>
# bot-bottle # claude-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/claude-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/claude-bottle/actions?workflow=test.yml)
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 "bot-bottle"? ## Why "claude-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,17 +37,6 @@ 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
@@ -70,7 +59,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, bot-bottle auto-detects it and launches is registered with Docker, claude-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`.
@@ -137,12 +126,10 @@ and MCP endpoints resolve without an agent-side change.
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
- **agent image** — built from the provider template Dockerfile - **agent image** — built from the repo `Dockerfile` (`node:22-slim`
(`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or base) on first run; runs `claude` with the manifest-granted skills,
`agent_provider.dockerfile`) on first run; runs the selected agent env vars, and `~/.gitconfig` (the latter for the git-gate's
CLI with the manifest-granted skills, env vars, and `~/.gitconfig` `insteadOf` rules when `bottle.git` is set).
(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`
@@ -157,8 +144,14 @@ and MCP endpoints resolve without an agent-side change.
upstream has *now* (fail-closed if unreachable). The agent's upstream has *now* (fail-closed if unreachable). The agent's
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`, `~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
so push, fetch, clone, and pull all route through. The agent so push, fetch, clone, and pull all route through. The agent
never sees the upstream credential. Brought up only when never sees the upstream credential. If the upstream's hostname
`bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`. isn't resolvable from the gate container (e.g. a Tailscale-only
host whose public DNS points elsewhere), pin its IP via
`ExtraHosts: { "<hostname>": "<ip>" }` on the `bottle.git` entry —
the gate's `/etc/hosts` gets the override while the agent's
`insteadOf` rewrite still keys off the original hostname. Brought
up only when `bottle.git` has entries. Design in
`docs/prds/0008-git-gate.md`.
- **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine` - **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine`
base, stdlib-only) that holds API tokens declared in base, stdlib-only) that holds API tokens declared in
`bottle.cred_proxy.routes`. Each route names a `path`, `bottle.cred_proxy.routes`. Each route names a `path`,
@@ -201,7 +194,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
`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires `CLAUDE_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
@@ -230,11 +223,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
`~/.bot-bottle/`. Each bottle is one file in `bottles/`, each agent `~/.claude-bottle/`. Each bottle is one file in `bottles/`, each agent
is one file in `agents/`: is one file in `agents/`:
``` ```
~/.bot-bottle/ ~/.claude-bottle/
├── bottles/ ├── bottles/
│ ├── dev.md │ ├── dev.md
│ └── gitea-dev.md │ └── gitea-dev.md
@@ -247,148 +240,84 @@ 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>/.bot-bottle/agents/<name>.md`. Those agents reference `<repo>/.claude-bottle/agents/<name>.md`. Those agents reference
bottles defined in `~/.bot-bottle/bottles/` (the only place bottles defined in `~/.claude-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
directory only. A cloned repo cannot redirect a host env var to an directory only. A cloned repo cannot redirect a host env var to an
attacker-named upstream because it has no way to declare a bottle. attacker-named upstream because it has no way to declare a bottle.
### Bottle composition with `extends:` ### Example bottle (`~/.claude-bottle/bottles/gitea-dev.md`)
A bottle can inherit from another via `extends: <bottle-name>` so
operators don't have to duplicate a whole bottle file to vary one
field (PRD 0025). The parent's resolved config is the base; the
child's declared fields overlay. Merge rules:
- `env:` — dict merge, child wins on key collision.
- `git.user:` — per-field overlay (child's non-empty `name` /
`email` wins; empty falls through to parent).
- `git.remotes:` — dict merge by host, child wins on host collision.
An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes.
- `agent_provider:`, `egress:`, `supervise:` — full replace when the
child declares the field.
```yaml
---
extends: dev # inherit everything from bottles/dev.md
egress:
routes:
- host: staging.example.com
auth:
scheme: Bearer
token_ref: STAGING_TOKEN
---
```
Cycles (`A extends B extends A`), self-references, and missing
parents die at parse with a clear pointer. Bottles remain
`$HOME`-only — `extends:` preserves the trust boundary above.
### 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
git: git:
user: - Name: claude-bottle
name: "Eric Bauerfeld" Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
email: "eric+claude@dideric.is" IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
remotes: KnownHostKey: ssh-ed25519 AAAA...
gitea.dideric.is:
Name: bot-bottle # Routes declared here are held by a per-bottle cred-proxy sidecar,
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git # not the agent. Each route names a path the agent dials, the
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea # upstream the proxy forwards to, an auth_scheme, and a token_ref
KnownHostKey: ssh-ed25519 AAAA... # (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: provider The `gitea-dev` bottle. Backs my work on personal projects: Anthropic
auth through egress and gitea.dideric.is over SSH. OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea
API), and npm for publishing scoped packages.
```` ````
For a Codex-backed base bottle, set `agent_provider.template: codex`. ### Example agent (`~/.claude-bottle/agents/gitea-helper.md`)
The Codex template expects ChatGPT/device login state instead of an
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
agent. To let bot-bottle read the host's current Codex ChatGPT access
token and inject it from egress only for Codex's API calls, opt in
explicitly:
```yaml
agent_provider:
template: codex
forward_host_credentials: true
egress:
routes:
- host: auth.openai.com
path_allowlist:
- /api/accounts/deviceauth/
```
Run `codex login --device-auth` on the host before launch. The
launcher reads `tokens.access_token` from the host's
`~/.codex/auth.json`, verifies it is fresh user/device auth, and passes
it to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container gets
a dummy `~/.codex/auth.json` that preserves the host auth-mode shape
but replaces credential values with placeholders. It keeps the selected
ChatGPT account id so Codex sends requests for the same account while
egress owns the real bearer token. The agent never receives real access
tokens, refresh tokens, or `OPENAI_API_KEY`. The effective egress table
automatically adds or upgrades `api.openai.com` and `chatgpt.com` to
authenticated routes when `forward_host_credentials` is true.
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
--- ---
bottle: gitea-dev bottle: gitea-dev
skills: skills:
- init-prd - init-prd
git:
user:
name: gitea-helper
email: eric+gitea-helper@dideric.is
--- ---
You help maintain Gitea-hosted projects. You help maintain Gitea-hosted projects.
@@ -398,23 +327,10 @@ 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 — bot-bottle ignores them at launch but doesn't frontmatter — claude-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.
An agent may also declare `git.user` (`name` / `email`). It overlays
the referenced bottle's `git.user` per-field — the agent's non-empty
fields win, the rest fall through to the bottle — so two agents can
share one bottle and still commit under distinct identities without
an identity-only bottle (PRD 0027). Only `git.user` is allowed at the
agent level; `git.remotes` stays bottle-only because it carries
credentials and host trust. The launch preflight and `cli.py info`
print the effective identity annotated `(agent)` / `(bottle)` so you
can see where each field came from. Git authorship is not a
credential — push auth is the bottle's remote key/token — so a
repo-shipped agent setting its own identity grants no access; treat
an agent identity as *claimed, not vouched*.
Unknown top-level frontmatter keys die at load with a "did you mean" Unknown top-level frontmatter keys die at load with a "did you mean"
pointer; typos don't silently ghost into an empty config. pointer; typos don't silently ghost into an empty config.
@@ -424,26 +340,25 @@ 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
`bot_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML). `claude_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: Claude OAuth token, not API key ## Auth: OAuth token, not API key
Bottles that use `agent_provider.template: claude` authenticate claude-bottle authenticates `claude` inside the container with the same
`claude` inside the container with the same Pro/Max subscription you Pro/Max subscription you already use on the host, via a long-lived OAuth
already use on the host, via a long-lived OAuth token. No token. No `ANTHROPIC_API_KEY` is needed.
`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
bot-bottle uses the env-var path on every host. claude-bottle uses the env-var path on every host.
**One-time setup on the host:** **One-time setup on the host:**
@@ -452,45 +367,28 @@ claude setup-token # browser login, prints a ~1-year OAuth token
``` ```
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager) Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`: as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
```sh ```sh
export BOT_BOTTLE_CLAUDE_OAUTH_TOKEN="<token>" export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
``` ```
The Claude bottle reaches the Anthropic API only through the cred-proxy The bottle reaches the Anthropic API only through the cred-proxy
sidecar. To let `claude` authenticate, declare an egress route with sidecar. To let `claude` authenticate, declare a route in
`role: claude_code_oauth` and `bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
`token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`: `token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
```yaml ```jsonc
egress: {
routes: "path": "/anthropic/",
- host: api.anthropic.com "upstream": "https://api.anthropic.com",
role: claude_code_oauth "auth_scheme": "Bearer",
auth: "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
scheme: Bearer "role": "anthropic-base-url"
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN }
pipelock:
tls_passthrough: true
``` ```
Routes that resolve to private or Tailscale addresses can opt into At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host
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
@@ -499,7 +397,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 Claude bottle without a `claude_code_oauth` route has no path to the A bottle without an `anthropic-base-url` 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`
@@ -509,7 +407,7 @@ via `claude setup-token` again. Reference:
## Trademarks ## Trademarks
bot-bottle is an independent project and is not affiliated with, claude-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
-26
View File
@@ -1,26 +0,0 @@
{
"bottles": {
"demo": {
"env": {
"FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
},
"git-gate": {
"repos": {
"foo": {
"url": "ssh://git@upstream.invalid/path.git",
"identity": "~/.cache/bot-bottle-demo/fake-key",
"host_key": "ssh-ed25519 AAAAEXAMPLE"
}
}
}
}
},
"agents": {
"demo": {
"bottle": "demo",
"skills": [],
"prompt": "You are inside bot-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
}
}
}
-1
View File
@@ -1 +0,0 @@
"""bot-bottle: Python implementation of the agent container launcher."""
-264
View File
@@ -1,264 +0,0 @@
"""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
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
PROVIDER_CLAUDE = "claude"
PROVIDER_CODEX = "codex"
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
# Hosts that egress injects the host ChatGPT bearer on when Codex
# forward_host_credentials is enabled. Pipelock must pass these through
# (no TLS MITM) or its header DLP blocks the injected JWT.
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
PromptMode = Literal["append_file", "read_prompt_file"]
@dataclass(frozen=True)
class AgentProviderRuntime:
template: str
command: str
image: str
dockerfile: str
prompt_mode: PromptMode
bypass_args: tuple[str, ...]
resume_args: tuple[str, ...]
remote_control_args: tuple[str, ...]
@dataclass(frozen=True)
class AgentProvisionDir:
guest_path: str
mode: str = "700"
owner: str = "node:node"
@dataclass(frozen=True)
class AgentProvisionFile:
host_path: Path
guest_path: str
mode: str = "600"
owner: str = "node:node"
@dataclass(frozen=True)
class AgentProvisionCommand:
argv: tuple[str, ...]
error: str = ""
@dataclass(frozen=True)
class AgentProvisionPlan:
"""Provider-owned guest setup.
Backends interpret this plan with their own copy/exec primitives.
Provider-specific content stays here so future provider plugins can
return the same shape without adding backend-plan fields.
`egress_routes` are provider-declared EgressRoutes that backends
pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps
provider logic out of the egress and pipelock modules — they merge
provider routes generically without knowing the provider type.
`hidden_env_names` is the set of env var names the provider injected
as non-secret placeholders. `print_util.visible_agent_env_names` uses
this to suppress them from the preflight summary so operators don't
mistake them for real credentials.
"""
template: str
command: str
prompt_mode: PromptMode
image: str
dockerfile: str
guest_env: dict[str, str]
env_vars: dict[str, str] = field(default_factory=dict)
dirs: tuple[AgentProvisionDir, ...] = ()
files: tuple[AgentProvisionFile, ...] = ()
pre_copy: tuple[AgentProvisionCommand, ...] = ()
verify: tuple[AgentProvisionCommand, ...] = ()
egress_routes: tuple[EgressRoute, ...] = ()
hidden_env_names: frozenset[str] = field(default_factory=frozenset)
provisioned_env: dict[str, str] = field(default_factory=dict)
_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"),
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"),
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 agent_provision_plan(
*,
template: str,
dockerfile: str,
state_dir: Path,
guest_home: str = "/home/node",
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None,
trusted_project_path: str = "",
) -> AgentProvisionPlan:
runtime = runtime_for(template)
resolved_guest_env = dict(guest_env or {})
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {}
provisioned_env: dict[str, str] = {}
dirs: list[AgentProvisionDir] = []
files: list[AgentProvisionFile] = []
pre_copy: list[AgentProvisionCommand] = []
verify: list[AgentProvisionCommand] = []
egress_routes: list[EgressRoute] = []
hidden_env_names: frozenset[str] = frozenset()
if template == PROVIDER_CODEX:
env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt"
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
if forward_host_credentials:
env_vars["CODEX_HOME"] = auth_dir
dirs.append(AgentProvisionDir(auth_dir))
config_path = f"{auth_dir}/config.toml"
config_file = state_dir / "codex-config.toml"
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
config_file.write_text(
f'[projects."{toml_path}"]\n'
'trust_level = "trusted"\n'
)
config_file.chmod(0o600)
files.append(AgentProvisionFile(config_file, config_path))
for host in CODEX_HOST_CREDENTIAL_HOSTS:
egress_routes.append(EgressRoute(
host=host,
auth_scheme="Bearer" if forward_host_credentials else "",
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
tls_passthrough=True,
))
if forward_host_credentials:
_host_env = host_env or dict(os.environ)
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = codex_host_access_token(
_host_env,
)
auth_file = state_dir / "codex-auth.json"
write_codex_dummy_auth_file(auth_file, _host_env)
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
pre_copy.append(AgentProvisionCommand((
"find", auth_dir,
"-maxdepth", "1",
"-type", "f",
"(",
"-name", "*.sqlite",
"-o", "-name", "*.sqlite-*",
"-o", "-name", "*.codex-repair-*.bak",
")",
"-delete",
), "codex host credentials: could not reset runtime db files"))
verify.append(AgentProvisionCommand((
"runuser", "-u", "node", "--",
"env",
f"HOME={guest_home}",
f"CODEX_HOME={auth_dir}",
"codex", "login", "status",
), (
"codex host credentials: dummy auth was copied into the "
"guest, but Codex did not accept it"
)))
if template == PROVIDER_CLAUDE:
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
env_vars["DISABLE_ERROR_REPORTING"] = "1"
claude_config = state_dir / "claude.json"
claude_projects = {
guest_home: {"hasTrustDialogAccepted": True},
}
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
claude_config.write_text(json.dumps({
"hasCompletedOnboarding": True,
"theme": "dark",
"bypassPermissionsModeAccepted": True,
"projects": claude_projects,
}, indent=2) + "\n")
claude_config.chmod(0o600)
files.append(AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"))
egress_routes.append(EgressRoute(
host="api.anthropic.com",
auth_scheme="Bearer" if auth_token else "",
token_ref=auth_token,
tls_passthrough=True,
))
if auth_token:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
return AgentProvisionPlan(
template=template,
command=runtime.command,
prompt_mode=runtime.prompt_mode,
image=runtime.image,
dockerfile=dockerfile,
env_vars=env_vars,
guest_env=resolved_guest_env,
dirs=tuple(dirs),
files=tuple(files),
pre_copy=tuple(pre_copy),
verify=tuple(verify),
egress_routes=tuple(egress_routes),
hidden_env_names=hidden_env_names,
provisioned_env=provisioned_env,
)
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}")
-56
View File
@@ -1,56 +0,0 @@
"""DockerBottlePlan — concrete subclass of BottlePlan.
Carries the Docker-specific resolved fields produced by
DockerBottleBackend.prepare. The launch step consumes it without
further resolution; preflight rendering is inherited from BottlePlan.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from ...agent_provider import PromptMode
from ...pipelock import PipelockProxyPlan
from .. import BottlePlan
@dataclass(frozen=True)
class DockerBottlePlan(BottlePlan):
"""Docker-specific resolved fields produced by
DockerBottleBackend.prepare. Inherits `spec`, `stage_dir`,
`git_gate_plan`, `egress_plan`, `supervise_plan`, and
`agent_provision` from BottlePlan."""
slug: str
container_name: str
container_name_pinned: bool
image: str
derived_image: str # "" -> no derived image
runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016).
dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any
# accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path
proxy_plan: PipelockProxyPlan
use_runsc: bool
@property
def agent_command(self) -> str:
return self.agent_provision.command
@property
def agent_prompt_mode(self) -> PromptMode:
return self.agent_provision.prompt_mode
@property
def agent_provider_template(self) -> str:
return self.agent_provision.template
-123
View File
@@ -1,123 +0,0 @@
"""Git provisioning inside a running Docker bottle.
Three concerns, all about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that .git
into the planned guest workspace so the agent operates on the
user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation
against a declared upstream (push, fetch, clone, pull,
ls-remote) transparently hits the per-agent git-gate. The
gate mirrors the upstream in both directions, so URL
rewriting is symmetric.
3. If the bottle declares `git.user` (issue #86), set
`git config --global user.{name,email}` inside the bottle so
the agent's commits are attributed to that identity.
"""
from __future__ import annotations
import os
import subprocess
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
from ....log import info
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan
def provision_git(plan: DockerBottlePlan, target: str) -> None:
"""Set up git inside the bottle. Runs all three subcases; each
no-ops when its condition isn't met."""
_provision_cwd_git(plan, target)
_provision_git_gate_config(plan, target)
_provision_git_user(plan, target)
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op
otherwise."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
return
container = target
guest_workspace_git = f"{workspace.guest_path}/.git"
host_git = str(workspace.host_path / ".git")
info(f"copying {host_git} -> {container}:{guest_workspace_git}")
subprocess.run(
["docker", "cp", host_git, f"{container}:{guest_workspace_git}"],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
[
"docker", "exec", "-u", "0", container,
"chown", "-R", workspace.owner, guest_workspace_git,
],
stdout=subprocess.DEVNULL,
check=True,
)
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
"""Write ~/.gitconfig in the bottle with the git-gate
insteadOf rules. No-op when the bottle has no `git` entries."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not bottle.git:
return
container = target
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig"
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
config_file = plan.stage_dir / "agent_gitconfig"
config_file.write_text(content)
config_file.chmod(0o600)
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
subprocess.run(
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
"""Apply `git config --global user.{name,email}` inside the
bottle so the agent's commits are attributed to the operator-
chosen identity instead of the agent image's default
(which is no user — git would refuse to commit at all
until the agent ran its own `git config`).
Runs as the `node` user so `--global` lands in
`/home/node/.gitconfig` (matching the existing
`_provision_git_gate_config` write location). No-op when the
bottle didn't declare `git.user`.
Each field set independently — name-only or email-only
configs only run the `git config` line for the field
present."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
gu = bottle.git_user
if gu.is_empty():
return
if gu.name:
info(f"git config --global user.name = {gu.name!r}")
subprocess.run(
["docker", "exec", "-u", "node", target,
"git", "config", "--global", "user.name", gu.name],
stdout=subprocess.DEVNULL,
check=True,
)
if gu.email:
info(f"git config --global user.email = {gu.email!r}")
subprocess.run(
["docker", "exec", "-u", "node", target,
"git", "config", "--global", "user.email", gu.email],
stdout=subprocess.DEVNULL,
check=True,
)
@@ -1,36 +0,0 @@
"""Provision non-secret provider auth markers into a Docker bottle."""
from __future__ import annotations
import subprocess
from ..bottle_plan import DockerBottlePlan
def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None:
"""Apply provider-owned guest setup through Docker primitives."""
provision = plan.agent_provision
for d in provision.dirs:
_exec(target, ["mkdir", "-p", d.guest_path])
_exec(target, ["chown", d.owner, d.guest_path])
_exec(target, ["chmod", d.mode, d.guest_path])
for command in provision.pre_copy:
_exec(target, list(command.argv))
for f in provision.files:
subprocess.run(
["docker", "cp", str(f.host_path), f"{target}:{f.guest_path}"],
stdout=subprocess.DEVNULL,
check=True,
)
_exec(target, ["chown", f.owner, f.guest_path])
_exec(target, ["chmod", f.mode, f.guest_path])
for command in provision.verify:
_exec(target, list(command.argv))
def _exec(target: str, argv: list[str]) -> None:
subprocess.run(
["docker", "exec", "-u", "0", target, *argv],
stdout=subprocess.DEVNULL,
check=True,
)
-471
View File
@@ -1,471 +0,0 @@
"""End-to-end launch flow for the smolmachines backend
(PRD 0023 chunks 2d + 4b).
Brings up the per-bottle docker bridge + sidecar bundle (with
real daemons + their config files), creates + starts the smolvm
guest pointed at the bundle's pinned IP via TSI's
`--allow-cidr <bundle-ip>/32` allowlist, yields a
`SmolmachinesBottle` handle, tears everything down on context
exit.
The bundle's daemons consume the inner Plans the docker backend
already produces: pipelock reads its yaml + CA from the
PipelockProxyPlan; egress reads routes + CAs from the EgressPlan
+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle
local), since the agent dials pipelock first (not egress) on the
smolmachines path. Git-gate + supervise plumb through the same
plans the docker backend uses, minus the docker-network fields
that don't apply here."""
from __future__ import annotations
import dataclasses
import os
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator
from ...egress import (
EGRESS_ROUTES_IN_CONTAINER,
egress_resolve_token_values,
)
from ...pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
)
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde
from ..docker import util as docker_mod
from ..docker.egress import (
EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
EGRESS_PORT as _EGRESS_PORT,
egress_tls_init,
)
from ..docker.git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ..docker.pipelock import (
BUNDLE_LOCAL_PIPELOCK_URL,
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
pipelock_tls_init,
)
from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle
from .bottle_plan import SmolmachinesBottlePlan
from .local_registry import crane_push_tarball, ephemeral_registry
# Repo root, used as the `docker build` context for the agent image.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
# Per-host cache for `smolvm pack create` outputs. Keyed by the
# docker image ID so a Dockerfile change automatically invalidates
# the cache. `pack create` is idempotent on the smolvm side but
# takes several seconds even on a no-op rebuild.
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines"
# Container-internal listening ports for each bundle daemon. The
# bundle publishes each one on a random host loopback port (see
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
# them up post-start. Pipelock's port is an env-overridable string
# in docker.pipelock; coerce to int here.
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
_GIT_HTTP_PORT = 9420
_SUPERVISE_PORT = SUPERVISE_PORT
@contextmanager
def launch(
plan: SmolmachinesBottlePlan,
*,
provision: Callable[[SmolmachinesBottlePlan, str], str | None],
) -> Generator[SmolmachinesBottle, None, None]:
"""Build + run the bottle and yield a handle; tear everything
down on exit. Errors during bringup unwind any partial state
via the ExitStack."""
stack = ExitStack()
try:
loopback_ip, network = _allocate_resources(plan, stack)
plan = _mint_certs(plan)
plan = _start_bundle(plan, network, loopback_ip, stack)
plan = _discover_urls(plan, loopback_ip)
# Build the agent image and pack it into a `.smolmachine`
# artifact (or hit the per-Dockerfile-digest cache). Runs
# here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine(
plan.agent_image_ref,
dockerfile=plan.agent_dockerfile_path,
)
_launch_vm(plan, agent_from_path, loopback_ip, stack)
_init_vm(plan)
prompt_path = provision(plan, plan.machine_name)
yield SmolmachinesBottle(
plan.machine_name,
prompt_path=prompt_path,
guest_env=plan.guest_env,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
finally:
stack.close()
def _allocate_resources(
plan: SmolmachinesBottlePlan,
stack: ExitStack,
) -> tuple[str, str]:
"""Reserve a loopback alias and create the per-bottle docker bridge.
macOS only routes 127.0.0.1 by default; the per-bottle alias
scopes TSI's allowlist to this bottle's published ports so the
agent can't reach other bottles' or host services' ports on
loopback. No-op on Linux."""
_loopback.ensure_pool()
loopback_ip = _loopback.allocate(plan.slug)
network = _bundle.bundle_network_name(plan.slug)
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
stack.callback(_bundle.remove_bundle_network, network)
return loopback_ip, network
def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
"""Mint per-bottle CAs and return the plan with CA paths filled.
Pipelock always runs in the bundle. Egress's CA is only minted
when the bottle declares routes — otherwise egress runs idle
without MITM and the CA files would be unused."""
ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent)
proxy_plan = dataclasses.replace(
plan.proxy_plan,
ca_cert_host_path=ca_cert_host,
ca_key_host_path=ca_key_host,
)
egress_plan = plan.egress_plan
if egress_plan.routes:
egress_ca_host, egress_ca_cert_only = egress_tls_init(
plan.egress_plan.routes_path.parent,
)
egress_plan = dataclasses.replace(
egress_plan,
mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
pipelock_ca_host_path=ca_cert_host,
# On smolmachines, egress's upstream is pipelock on the
# bundle's localhost — they're in the same container's
# network namespace.
pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL,
)
return dataclasses.replace(plan, proxy_plan=proxy_plan, egress_plan=egress_plan)
def _start_bundle(
plan: SmolmachinesBottlePlan,
network: str,
loopback_ip: str,
stack: ExitStack,
) -> SmolmachinesBottlePlan:
"""Build the BundleLaunchSpec, resolve token env, start the
sidecar bundle container, and register teardown."""
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
token_env = _resolve_token_env(plan, dict(os.environ))
_bundle.ensure_bundle_image(bundle_spec.image)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
stack.callback(_bundle.stop_bundle, plan.slug)
return plan
def _discover_urls(
plan: SmolmachinesBottlePlan,
loopback_ip: str,
) -> SmolmachinesBottlePlan:
"""Discover host-side ports for published container ports and
return the plan with URLs + guest_env stamped in.
Docker container IPs (192.168.x.x in the daemon's bridge)
aren't reachable from the smolvm guest on macOS — TSI uses
macOS networking, and macOS sees the daemon's bridge via the
published-port loopback forward only.
Proxy hop order: when the bottle declares egress routes, the
agent's first hop is egress (for token injection), then
pipelock. Without routes, the agent dials pipelock directly.
NO_PROXY includes the per-bottle loopback alias so the
supervise + git-gate URLs bypass HTTPS_PROXY."""
if plan.egress_plan.routes:
agent_facing_port = _EGRESS_PORT
else:
agent_facing_port = _PIPELOCK_PORT
agent_facing_host_port = _bundle.bundle_host_port(
plan.slug, agent_facing_port, host_ip=loopback_ip,
)
agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
agent_git_gate_host = ""
if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port(
plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip,
)
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_supervise_url = ""
if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
)
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
guest_env = {
**plan.guest_env,
"HTTPS_PROXY": agent_proxy_url,
"HTTP_PROXY": agent_proxy_url,
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
}
if agent_git_gate_host:
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
if agent_supervise_url:
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
return dataclasses.replace(
plan,
guest_env=guest_env,
agent_proxy_url=agent_proxy_url,
agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url,
)
def _launch_vm(
plan: SmolmachinesBottlePlan,
agent_from_path: Path,
loopback_ip: str,
stack: ExitStack,
) -> None:
"""Create, patch, and start the smolvm VM; register teardown.
--allow-cidr is the per-bottle loopback alias so the guest can
only reach this bottle's bundle ports. force_allowlist patches
smolvm 0.8.0's silent-drop of --allow-cidr when combined with
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
and --smolfile mutually exclusive."""
_smolvm.machine_create(
plan.machine_name,
from_path=agent_from_path,
allow_cidrs=[f"{loopback_ip}/32"],
env=plan.guest_env,
)
stack.callback(_smolvm.machine_delete, plan.machine_name)
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
# when combined with `--from`. Patch the persisted state DB
# before start so the booted VM's TSI actually enforces.
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
_smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name)
def _init_vm(plan: SmolmachinesBottlePlan) -> None:
"""Repair filesystem ownership and wait for exec channel readiness.
Ownership repair: smolvm's pack process remaps files to the host
invoker's uid (501 on macOS). /home/node must be node:node so
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
mode 1777 so non-root processes can create per-uid scratch dirs.
All folded into one sh -c to avoid back-to-back exec calls
immediately after machine_start (libkrun exec-channel race).
wait_exec_ready polls until the exec channel is ready for the
subsequent provision calls, replacing the empirical sleep."""
_smolvm.machine_exec(plan.machine_name, [
"sh", "-c",
"chown -R node:node /home/node && "
"chown root:root /tmp /var/tmp && "
"chmod 1777 /tmp /var/tmp",
])
_smolvm.wait_exec_ready(plan.machine_name)
def _bundle_launch_spec(
plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
) -> _bundle.BundleLaunchSpec:
"""Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV:
- egress + pipelock are always present (pipelock is the
agent's first hop; egress is its upstream).
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan.
Env + volumes are the union of the sidecar daemons' needs, with
daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh — see PRD 0024's bundle
bind-address PR)."""
daemons: list[str] = ["egress", "pipelock"]
env: list[str] = []
volumes: list[tuple[str, str, bool]] = []
# In this Docker-Desktop-compatible topology, whichever daemon
# is "agent-facing" gets its port published on the host
# loopback (see `_ensure_smolmachine`'s discovery loop) and the
# other stays bundle-internal. The bundle is NOT reachable by
# bridge IP from the smolvm guest on macOS — TSI uses macOS
# networking, and macOS sees the daemon's bridge via the
# published-port loopback forward only.
# --- pipelock ---------------------------------------------
pp = plan.proxy_plan
volumes += [
(str(pp.yaml_path), "/etc/pipelock.yaml", True),
(str(pp.ca_cert_host_path), PIPELOCK_CA_CERT_IN_CONTAINER, True),
(str(pp.ca_key_host_path), PIPELOCK_CA_KEY_IN_CONTAINER, True),
]
# --- egress -----------------------------------------------
ep = plan.egress_plan
if ep.routes:
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}")
env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}")
volumes += [
(str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True),
(str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True),
(str(ep.pipelock_ca_host_path), EGRESS_PIPELOCK_CA_IN_CONTAINER, True),
]
# Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv.
for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env)
# --- git-gate ---------------------------------------------
gp = plan.git_gate_plan
if gp.upstreams:
daemons += ["git-gate", "git-http"]
volumes += [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, True),
]
for u in gp.upstreams:
keypath = expand_tilde(u.identity_file)
volumes.append((
keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
True,
))
if u.known_hosts_file:
volumes.append((
str(u.known_hosts_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
True,
))
# --- supervise --------------------------------------------
sp = plan.supervise_plan
if sp is not None:
daemons.append("supervise")
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI +
# macOS networking. The HTTP/HTTPS chokepoint is whichever
# daemon's port we publish: egress when routes are declared
# (token injection first, then forwards to bundle-internal
# pipelock), pipelock otherwise.
if ep.routes:
ports_to_publish: list[int] = [_EGRESS_PORT]
else:
ports_to_publish = [_PIPELOCK_PORT]
if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT)
if sp is not None:
ports_to_publish.append(_SUPERVISE_PORT)
return _bundle.BundleLaunchSpec(
slug=plan.slug,
network_name=network,
subnet=plan.bundle_subnet,
gateway=plan.bundle_gateway,
bundle_ip=plan.bundle_ip,
daemons_csv=",".join(daemons),
environment=tuple(env),
volumes=tuple(volumes),
ports_to_publish=tuple(ports_to_publish),
publish_host_ip=loopback_ip,
)
def _resolve_token_env(
plan: SmolmachinesBottlePlan, host_env: dict[str, str],
) -> dict[str, str]:
"""Resolve the egress token env-var values from the host's
environ so they reach the bundle's process env via docker's
`-e NAME` inheritance. Empty when no routes declare auth."""
effective_env = {**host_env, **plan.agent_provision.provisioned_env}
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
"""Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under
`~/.cache/bot-bottle/smolmachines/` keyed by the docker image
ID (so a Dockerfile change automatically invalidates the cache).
Returns the `.smolmachine.smolmachine` sidecar path — that's
the file `machine create --from` consumes (pack create produces
a launcher binary at `.smolmachine` plus the sidecar alongside
it; the sidecar is the actual artifact).
Conversion path: `docker build` (the existing layer cache
makes no-change rebuilds cheap) → `docker save` to a tarball
→ spin up an ephemeral registry on a private docker network →
`crane push --insecure` from a one-shot container on the same
network → `smolvm pack create --image localhost:<host port>/...`
→ tear down the registry + network. The crane push detour
sidesteps the Docker-Desktop daemon's HTTPS preference for
non-loopback registries — see the `local_registry` module
docstring for the gory details.
Each pack-create costs several seconds even on a hot cache,
so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
# keep filenames manageable, long enough to make collisions
# astronomically unlikely.
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
binary = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine"
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if sidecar.is_file():
return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
docker_mod.save(image_ref, str(tarball))
try:
with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
pack_ref = f"{handle.pull_endpoint}/bot-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary)
finally:
# Tarball is ~500MB-1GB for the agent image; reclaim once
# the smolmachine artifact exists. The artifact itself is
# the long-lived cache entry.
tarball.unlink(missing_ok=True)
return sidecar
-196
View File
@@ -1,196 +0,0 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
Resolves the per-bottle docker subnet + bundle IP and assembles
the guest env. The agent's docker image build → smolmachine
pack pipeline runs in `launch.launch`, not here, so the
dashboard's preflight modal isn't garbled by docker-build output
before the operator has confirmed.
No VM bringup — that's `launch.launch`'s job."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path
from ...agent_provider import agent_provision_plan, runtime_for
from ...backend import BottleSpec
from ...backend.docker.bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
egress_state_dir,
git_gate_state_dir,
pipelock_state_dir,
supervise_state_dir,
write_metadata,
)
from ...egress import Egress
from ...env import resolve_env
from ...git_gate import GitGate
from ...pipelock import PipelockProxy
from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Gateway ports the bundle exposes inside its container — pipelock
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent
# inside the smolvm guest dials these on the bundle's pinned IP.
_BUNDLE_PIPELOCK_PORT = 8888
_BUNDLE_GIT_GATE_PORT = 9418
_BUNDLE_SUPERVISE_PORT = 9100
def resolve_plan(
spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
"""Materialize the smolmachines plan. The bundle's docker
subnet + pinned IP are derived from the slug; the agent's
`.smolmachine` artifact is built (or cache-hit) here so
launch's `machine create --from` boots without a registry
pull. Per-bottle guest env + the TSI allow_cidrs land on the
plan for launch to pass straight through to
`machine create` flags."""
smolmachines_preflight()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node")
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = spec.identity or bottle_identity(spec.agent_name)
# Record minimal metadata so `cli.py resume` can recover the
# slug. Same schema as the docker backend.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project="",
backend="smolmachines",
))
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
# Agent's env: resolve through resolve_env() so ?prompt entries
# are prompted and ${HOST_VAR} entries are interpolated — matching
# the Docker backend's contract. Forwarded (secret/interpolated)
# values still reach the guest as -e K=V smolvm flags because
# smolvm 0.8.0 has no env-file or stdin injection path; this is
# the known argv-exposure gap documented in PRD 0038.
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
# in launch.py after bundle bringup.
resolved = resolve_env(manifest, spec.agent_name)
guest_env: dict[str, str] = {
**resolved.literals,
**resolved.forwarded,
"NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
}
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir)
# Prompt file is always written (mode 0o600) so the in-VM
# path always exists. Content is the agent's `prompt`
# field (markdown body) — empty for agents with no prompt.
# claude-code reads it via --append-system-prompt-file only
# when non-empty, but the file must exist either way to
# match the docker backend's contract.
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
agent = manifest.agents[spec.agent_name]
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
machine_name = f"bot-bottle-{slug}"
# Stash the agent image ref — `launch.launch` runs the
# build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE
# to match the docker backend's `resolve_plan` default.
agent_dockerfile_path = ""
if provider.dockerfile:
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
image_default = f"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)
agent_provision = agent_provision_plan(
template=provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
guest_home=guest_home,
guest_env=guest_env,
forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
)
merged_guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items():
merged_guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=merged_guest_env)
# Inner Plans for the four bundle daemons. The ABCs are
# platform-neutral — `.prepare()` writes config files + returns
# a Plan dataclass with no backend-specific assumptions. State
# dirs are still keyed by slug under the docker backend's
# bottle_state layout (shared on-host convention; not a docker
# dependency).
pipelock_dir = pipelock_state_dir(slug)
pipelock_dir.mkdir(parents=True, exist_ok=True)
proxy_plan = PipelockProxy().prepare(
bottle, slug, pipelock_dir, agent_provision.egress_routes,
)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare(
bottle, slug, egress_dir, agent_provision.egress_routes,
)
supervise_plan = None
if bottle.supervise:
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = Supervise().prepare(slug, supervise_dir)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
bundle_subnet=subnet,
bundle_gateway=gateway,
bundle_ip=bundle_ip,
machine_name=machine_name,
agent_image_ref=agent_image_ref,
guest_env=agent_provision.guest_env,
prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
agent_provision=agent_provision,
workspace_plan=workspace_plan,
)
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,33 +0,0 @@
"""Provision non-secret provider auth markers into a smolmachines bottle."""
from __future__ import annotations
from ....log import die
from .. import smolvm as _smolvm
from ..bottle_plan import SmolmachinesBottlePlan
def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None:
"""Apply provider-owned guest setup through smolvm primitives."""
provision = plan.agent_provision
for d in provision.dirs:
_exec(target, ["mkdir", "-p", d.guest_path], f"could not create {d.guest_path}")
_exec(target, ["chown", d.owner, d.guest_path], f"could not chown {d.guest_path}")
_exec(target, ["chmod", d.mode, d.guest_path], f"could not chmod {d.guest_path}")
for command in provision.pre_copy:
_exec(target, list(command.argv), command.error)
for f in provision.files:
_smolvm.machine_cp(str(f.host_path), f"{target}:{f.guest_path}")
_exec(target, ["chown", f.owner, f.guest_path], f"could not chown {f.guest_path}")
_exec(target, ["chmod", f.mode, f.guest_path], f"could not chmod {f.guest_path}")
for command in provision.verify:
_exec(target, list(command.argv), command.error)
def _exec(target: str, argv: list[str], error: str) -> None:
result = _smolvm.machine_exec(target, argv)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
if detail:
detail = f": {detail}"
die(f"agent provider provisioning: {error}{detail}")
@@ -1,36 +0,0 @@
"""Copy the operator workspace into a smolmachines guest."""
from __future__ import annotations
import shlex
from ....log import info
from .. import smolvm as _smolvm
from ..bottle_plan import SmolmachinesBottlePlan
def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
"""Copy host cwd contents to the planned guest workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path_q = shlex.quote(workspace.guest_path)
guest_parent_q = shlex.quote(guest_parent)
owner_q = shlex.quote(workspace.owner)
mode_q = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}")
_smolvm.machine_exec(
target,
["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"],
)
_smolvm.machine_cp(str(workspace.host_path), f"{target}:{workspace.guest_path}")
_smolvm.machine_exec(
target,
[
"sh", "-c",
f"chown -R {owner_q} {guest_path_q} && "
f"chmod {mode_q} {guest_path_q}",
],
)
@@ -1,149 +0,0 @@
"""Host-side SIGWINCH → in-VM PTY resize bridge (issue #82).
smolvm 0.8.0 `machine exec -t` allocates an in-VM PTY but never
forwards the host terminal's window size (TIOCSWINSZ) to it. The
PTY's initial size is `0 0`, and any host-side resize during the
session goes unnoticed — the in-VM claude TUI keeps rendering for
whatever (typically tiny) box it last saw, ignoring the operator's
tmux pane resize. `docker exec -it` does this forwarding
automatically; smolvm doesn't.
This module wraps `smolvm machine exec` with a thin parent
process that:
1. Spawns the original argv as a child (it gets the inherited
TTY, so claude's stdin/stdout/stderr work unchanged).
2. On startup + every host SIGWINCH, reads the host terminal
size via TIOCGWINSZ on stdin (or stderr if stdin isn't a
TTY — tmux respawn-pane gives us a TTY on stdout/stderr)
and pushes it into the VM with a side-channel
`smolvm machine exec -- sh -c 'for f in /dev/pts/*; do
stty -F $f cols X rows Y; done'`. The kernel delivers
SIGWINCH to the foreground process group on the slave end
automatically, so claude picks up the new size without
extra signalling.
3. Waits on the child and exits with its returncode.
The dashboard's tmux pane respawn calls `bottle.agent_argv`
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
to the smolvm argv. Foreground handoff (curses endwin →
subprocess.run) goes through the same path so behavior is
identical.
Removable once smolvm grows native SIGWINCH forwarding (upstream
follow-up tracked separately)."""
from __future__ import annotations
import fcntl
import signal
import struct
import subprocess
import sys
import termios
import threading
# How long to wait after the main exec starts before pushing the
# initial size. Concurrent `smolvm machine exec` invocations race
# libkrun's per-exec OCI config write during the main exec's
# bringup window; the side-channel firing immediately corrupts
# `config.json` and the main exec dies with SIGKILL (rc=137) or
# libkrun's "parse error: trailing garbage" depending on
# scheduling. Two seconds is well past the bringup window on a
# warm VM, well under the operator's "this is unresponsive"
# threshold, and short enough that claude's initial render
# almost always fires after the size has been set.
_STARTUP_SYNC_DELAY_SEC = 2.0
def _read_winsize() -> tuple[int, int] | None:
"""Return `(rows, cols)` from whichever of stdin / stdout /
stderr is a TTY, or None if none are. Different invocation
surfaces give us different TTYs:
- foreground handoff (curses endwin → subprocess.run): all
three are the operator's terminal.
- tmux respawn-pane: tmux sets all three to the pane's PTY.
- non-TTY (someone piped stdin in tests): none are; the
sync just no-ops, which is the right behavior."""
for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()):
try:
data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
except OSError:
continue
rows, cols, _, _ = struct.unpack("hhhh", data)
if rows > 0 and cols > 0:
return rows, cols
return None
def _push_size(machine: str, rows: int, cols: int) -> None:
"""Side-channel `smolvm machine exec` that sets the size of
every PTY in the VM. The shell `for` loop covers the case of
multiple concurrent interactive sessions (rare but cheap to
handle); `stty -F` returns silently on PTYs that don't apply.
Best-effort: swallow failures. A failed resize doesn't break
the session — it just leaves the in-VM PTY at its old size.
`stdin=DEVNULL` is load-bearing: under tmux, inheriting the
pane PTY here means two concurrent smolvm processes (this one
and the agent session the wrapper is shepherding) share the
PTY's foreground-process-group / input plumbing, and smolvm
bails with an internal config-parse error or SIGKILL within
~100ms of the side-channel firing. Outside tmux the same
pattern survived, presumably because iTerm's PTY plumbing is
more forgiving than tmux's, but the DEVNULL is the right
default either way — the side-channel never needs stdin."""
subprocess.run(
["smolvm", "machine", "exec", "--name", machine, "--",
"sh", "-c",
f"for f in /dev/pts/*; do "
f"stty -F \"$f\" cols {cols} rows {rows} 2>/dev/null; "
f"done"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
def main(argv: list[str]) -> int:
"""Entry point. `argv` shape: `<machine> -- <smolvm-argv...>`.
We don't use argparse — the `--` separator is the contract and
everything past it is forwarded verbatim. Keeps the wrapper
transparent for callers building argv programmatically."""
if len(argv) < 3 or argv[1] != "--":
sys.stderr.write(
"usage: python -m bot_bottle.backend.smolmachines.pty_resize "
"<machine> -- <smolvm-argv...>\n"
)
return 2
machine = argv[0]
inner = argv[2:]
def sync(*_args) -> None:
size = _read_winsize()
if size is None:
return
_push_size(machine, *size)
signal.signal(signal.SIGWINCH, sync)
proc = subprocess.Popen(inner)
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
# daemon=True so the timer doesn't block exit when the child
# finishes before the delay elapses.
timer = threading.Timer(_STARTUP_SYNC_DELAY_SEC, sync)
timer.daemon = True
timer.start()
while True:
try:
return proc.wait()
except KeyboardInterrupt:
proc.send_signal(signal.SIGINT)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-77
View File
@@ -1,77 +0,0 @@
"""Cross-backend utility helpers — host-side primitives shared by
every backend implementation. Backend-specific helpers live one level
deeper (e.g. bot_bottle/backend/docker/util.py)."""
from __future__ import annotations
import hashlib
import os
import ssl
from pathlib import Path
from typing import TYPE_CHECKING
from ..log import die, info
if TYPE_CHECKING:
from ..egress import EgressPlan
from ..pipelock import PipelockProxyPlan
# Debian-family CA layout, shared by every backend (all guest images
# are Debian-family). AGENT_CA_PATH is the source path that
# `update-ca-certificates` reads; AGENT_CA_BUNDLE is the bundle it
# rebuilds, which curl, Python `ssl`, and OpenSSL-based tools all read
# by default.
AGENT_CA_PATH = "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt"
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
def host_skill_dir(name: str) -> str:
"""Return the host-side path for a named skill:
`$HOME/.claude/skills/<name>`. Dies if HOME is unset."""
home = os.environ.get("HOME")
if not home:
die("HOME not set")
return f"{home}/.claude/skills/{name}"
def select_ca_cert(
egress_plan: EgressPlan, proxy_plan: PipelockProxyPlan
) -> tuple[Path, str]:
"""Pick the agent-facing CA cert (and a short label for the log
line) that matches the proxy the agent's HTTP_PROXY points at.
Egress wins when the bottle declares any routes (it sits in front
of pipelock); else pipelock.
Shared by every backend's `provision_ca`: launch mints the chosen
CA(s) and re-binds their host paths into these inner plans before
provision runs, so an empty/missing path here means launch's
bringup is broken — fatal."""
if egress_plan.routes:
cert = egress_plan.mitmproxy_ca_cert_only_host_path
if cert == Path() or not cert.is_file():
die(
f"egress CA cert missing at {cert or '(empty)'}; "
f"launch must have called egress_tls_init and "
f"re-bound the plan before provision"
)
return cert, "egress"
cert = proxy_plan.ca_cert_host_path
if not cert or not cert.is_file():
die(
f"pipelock CA cert missing at {cert or '(empty)'}; "
f"launch must have called pipelock_tls_init and re-bound "
f"the plan before provision"
)
return cert, "pipelock"
def log_ca_fingerprint(cert_host_path: Path, label: str) -> None:
"""Compute the cert's SHA-256 fingerprint over its DER bytes
(stdlib `ssl` + `hashlib`) and log it once to stderr — the
standard fingerprint form. Only ever touches the public cert;
the private key stays on the host under the stage dir until
teardown."""
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
fingerprint = hashlib.sha256(der).hexdigest()
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
-325
View File
@@ -1,325 +0,0 @@
"""Host Codex auth helpers.
Reads the host's Codex ChatGPT/device-login auth state and returns only
the short-lived access token needed by egress. This module deliberately
does not expose refresh tokens or raw auth payloads.
"""
from __future__ import annotations
import base64
import json
import os
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from .log import die
from .util import expand_tilde
def codex_auth_path(host_env: dict[str, str] | None = None) -> Path:
env = os.environ if host_env is None else host_env
home = env.get("CODEX_HOME")
if home:
return Path(expand_tilde(home)) / "auth.json"
return Path.home() / ".codex" / "auth.json"
def codex_host_access_token(
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> str:
path = codex_auth_path(host_env)
if not path.is_file():
die(
f"codex host credentials: auth file missing at {path}. "
"Run `codex login --device-auth` on the host or disable "
"agent_provider.forward_host_credentials."
)
raw = _read_auth_object(path)
auth_mode = raw.get("auth_mode")
if not isinstance(auth_mode, str) or auth_mode == "api_key":
die(
"codex host credentials: host Codex auth is not user/device "
"auth. Run `codex login --device-auth` on the host."
)
tokens = raw.get("tokens")
if not isinstance(tokens, dict):
die(f"codex host credentials: {path} is missing tokens")
access = tokens.get("access_token")
if not isinstance(access, str) or not access:
die(
f"codex host credentials: {path} is missing tokens.access_token. "
"Run `codex login --device-auth` on the host."
)
exp = _jwt_exp(access)
if exp is None:
die("codex host credentials: tokens.access_token is not a JWT with exp")
check_now = now or datetime.now(timezone.utc)
if exp <= check_now:
die(
"codex host credentials: host Codex access token is expired. "
"Run `codex login --device-auth` on the host and restart the bottle."
)
return access
def codex_dummy_auth_json(
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> str:
"""Return a non-secret `auth.json` that keeps Codex in the host's
auth branch while egress owns the real bearer token.
The dummy access/id tokens carry the *host* token's real `exp` so
Codex's proactive refresh lifecycle (it refreshes when its local
access token is at/past expiry) tracks the real token instead of
firing after an artificial TTL. Codex cannot refresh inside the
bottle — the refresh token is a placeholder and the OpenAI token
endpoint is off-route — so a shorter dummy exp would drop Codex to
the sign-in screen the moment it lapsed, even while egress still
holds a valid bearer."""
path = codex_auth_path(host_env)
access = codex_host_access_token(host_env, now=now)
raw = _read_auth_object(path)
host_exp = _jwt_exp(access)
exp_ts = int(host_exp.timestamp()) if host_exp is not None else None
dummy = _redact_codex_auth(deepcopy(raw), now=now, exp_ts=exp_ts)
return json.dumps(dummy, indent=2, sort_keys=True) + "\n"
def write_codex_dummy_auth_file(
path: Path,
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(codex_dummy_auth_json(host_env, now=now))
path.chmod(0o600)
def _read_auth_object(path: Path) -> dict:
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
if not isinstance(raw, dict):
die(f"codex host credentials: {path} must contain a JSON object")
return raw
def _dummy_exp(now: datetime | None, exp_ts: int | None) -> int:
if exp_ts is not None:
return exp_ts
check_now = now or datetime.now(timezone.utc)
return int(check_now.timestamp()) + 3600
def _dummy_timestamp(now: datetime | None = None) -> str:
check_now = now or datetime.now(timezone.utc)
if check_now.tzinfo is None:
check_now = check_now.replace(tzinfo=timezone.utc)
check_now = check_now.astimezone(timezone.utc)
return check_now.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _dummy_jwt(now: datetime | None = None, *, exp_ts: int | None = None) -> str:
return _encode_dummy_jwt({
"exp": _dummy_exp(now, exp_ts),
"sub": "bot-bottle-placeholder",
})
def _dummy_jwt_from_host(
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
) -> str:
if not isinstance(value, str):
return _dummy_jwt(now, exp_ts=exp_ts)
parts = value.split(".")
if len(parts) < 2:
return _dummy_jwt(now, exp_ts=exp_ts)
try:
payload = json.loads(_b64url_decode(parts[1]))
except (ValueError, json.JSONDecodeError):
return _dummy_jwt(now, exp_ts=exp_ts)
if not isinstance(payload, dict):
return _dummy_jwt(now, exp_ts=exp_ts)
return _encode_dummy_jwt(_redact_jwt_payload(payload, now=now, exp_ts=exp_ts))
def _encode_dummy_jwt(payload: dict) -> str:
def enc(obj: dict) -> str:
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{enc({'alg': 'none', 'typ': 'JWT'})}.{enc(payload)}.placeholder"
def _redact_jwt_payload(
payload: dict,
*,
now: datetime | None = None,
exp_ts: int | None = None,
) -> dict:
out = _redact_claims(payload)
if not isinstance(out, dict):
out = {}
out["exp"] = _dummy_exp(now, exp_ts)
out.setdefault("sub", "bot-bottle-placeholder")
return out
def _redact_claims(value: object) -> object:
if isinstance(value, dict):
out: dict[str, object] = {}
for key, inner in value.items():
lower = key.lower()
if key == "https://api.openai.com/profile":
out[key] = _redact_profile_claim(inner)
elif key == "https://api.openai.com/auth":
out[key] = _redact_auth_claim(inner)
elif lower == "email":
out[key] = "bot-bottle@example.invalid"
elif lower == "email_verified":
out[key] = True
elif lower in {"exp", "iat", "nbf", "auth_time", "pwd_auth_time"}:
out[key] = inner if isinstance(inner, (int, float)) else 0
elif lower in {"aud", "scp", "amr"}:
out[key] = inner if isinstance(inner, list) else []
elif isinstance(inner, bool):
out[key] = inner
elif isinstance(inner, dict):
out[key] = {}
elif isinstance(inner, list):
out[key] = []
else:
out[key] = "bot-bottle-placeholder"
return out
if isinstance(value, list):
return []
return "bot-bottle-placeholder"
def _redact_profile_claim(value: object) -> dict:
profile = value if isinstance(value, dict) else {}
return {
"email": "bot-bottle@example.invalid",
"email_verified": bool(profile.get("email_verified", True)),
}
def _redact_auth_claim(value: object) -> dict:
auth = value if isinstance(value, dict) else {}
out: dict[str, object] = {}
for key, inner in auth.items():
lower = key.lower()
if lower == "chatgpt_plan_type" and isinstance(inner, str) and inner:
out[key] = inner
elif lower == "chatgpt_account_id" and isinstance(inner, str) and inner:
# Current Codex uses the selected account id when building
# ChatGPT requests. Keep that non-secret identifier aligned
# with the host while egress owns the real bearer token.
out[key] = inner
elif lower == "localhost" and isinstance(inner, bool):
out[key] = inner
elif isinstance(inner, bool):
out[key] = inner
elif isinstance(inner, list):
out[key] = []
elif isinstance(inner, dict):
out[key] = {}
else:
out[key] = "bot-bottle-placeholder"
out.setdefault("chatgpt_plan_type", "unknown")
out.setdefault("user_id", "bot-bottle-placeholder")
out.setdefault("chatgpt_user_id", "bot-bottle-placeholder")
out.setdefault("chatgpt_account_id", "bot-bottle-placeholder")
return out
def _redact_codex_auth(
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
) -> object:
auth = value if isinstance(value, dict) else {}
out: dict[str, object] = {}
for key, inner in auth.items():
lower = key.lower()
if lower == "auth_mode" and isinstance(inner, str) and inner:
out[key] = inner
elif lower == "openai_api_key":
out[key] = None
elif lower == "last_refresh":
# Codex parses this as a timestamp on startup. Keep the
# schema valid without copying host-side session metadata.
out[key] = _dummy_timestamp(now)
elif lower == "tokens":
out[key] = _redact_token_block(inner, now=now, exp_ts=exp_ts)
else:
out[key] = _redact_unknown_auth_value(inner)
return out
def _redact_token_block(
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
) -> dict[str, object]:
tokens = value if isinstance(value, dict) else {}
out: dict[str, object] = {}
for key, inner in tokens.items():
lower = key.lower()
if lower in {"access_token", "id_token"}:
out[key] = _dummy_jwt_from_host(inner, now=now, exp_ts=exp_ts)
elif lower == "account_id" and isinstance(inner, str) and inner:
# Current Codex uses this non-secret selected account id
# while egress owns the real bearer token.
out[key] = inner
else:
out[key] = _redact_unknown_auth_value(inner)
return out
def _redact_unknown_auth_value(value: object) -> object:
if isinstance(value, bool):
return value
if isinstance(value, dict):
return {}
if isinstance(value, list):
return []
if value is None:
return None
return "bot-bottle-placeholder"
def _jwt_exp(token: str) -> datetime | None:
parts = token.split(".")
if len(parts) < 2:
return None
try:
payload = json.loads(_b64url_decode(parts[1]))
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return None
return datetime.fromtimestamp(exp, timezone.utc)
def _b64url_decode(value: str) -> str:
padded = value + ("=" * (-len(value) % 4))
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
__all__ = [
"codex_auth_path",
"codex_dummy_auth_json",
"codex_host_access_token",
"write_codex_dummy_auth_file",
]
-175
View File
@@ -1,175 +0,0 @@
"""Tiny smart-HTTP wrapper for git-gate repos.
Used by the smolmachines backend where `git://` push traffic over the
host-published Docker port can hang before receive-pack reaches hooks.
The wrapper serves the same `/git/*.git` bare repos through
`git http-backend`, so pre-receive and upstream forwarding remain the
git-gate enforcement point.
"""
from __future__ import annotations
import os
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
DEFAULT_PORT = 9420
# Body-size cap matching supervise_server.py's 1 MiB limit.
MAX_BODY_BYTES = 1 * 1024 * 1024
class GitHttpHandler(BaseHTTPRequestHandler):
server_version = "bot-bottle-git-http/1"
def do_GET(self) -> None:
self._run_backend()
def do_POST(self) -> None:
self._run_backend()
def _run_backend(self) -> None:
parsed = urlsplit(self.path)
if self._is_upload_pack(parsed.path, parsed.query):
repo_dir = self._repo_dir(parsed.path)
if repo_dir is None:
self.send_error(404)
return
hook_path = os.environ.get(
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
)
peer = self.client_address[0]
hook = subprocess.run(
[hook_path, "upload-pack", str(repo_dir), peer, peer],
capture_output=True,
check=False,
)
if hook.returncode != 0:
detail = (hook.stderr or hook.stdout).decode(
"utf-8", errors="replace",
).rstrip()
if detail:
for line in detail.splitlines():
self.log_message("access-hook denied %s: %s",
parsed.path, line)
else:
self.log_message(
"access-hook denied %s: exit=%d (no output)",
parsed.path, hook.returncode,
)
self.send_response(403)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(hook.stderr or hook.stdout)
return
env = os.environ.copy()
env.update({
"GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"),
"GIT_HTTP_EXPORT_ALL": "1",
"REQUEST_METHOD": self.command,
"PATH_INFO": parsed.path,
"QUERY_STRING": parsed.query,
"CONTENT_TYPE": self.headers.get("content-type", ""),
"CONTENT_LENGTH": self.headers.get("content-length", "0"),
"REMOTE_ADDR": self.client_address[0],
"REMOTE_PORT": str(self.client_address[1]),
"REMOTE_USER": "",
"SERVER_NAME": self.server.server_name,
"SERVER_PORT": str(self.server.server_port),
"SERVER_PROTOCOL": self.request_version,
})
for header, variable in (
("accept", "HTTP_ACCEPT"),
("content-encoding", "HTTP_CONTENT_ENCODING"),
("git-protocol", "HTTP_GIT_PROTOCOL"),
("user-agent", "HTTP_USER_AGENT"),
):
value = self.headers.get(header)
if value:
env[variable] = value
raw_length = self.headers.get("content-length", "0") or "0"
try:
length = int(raw_length)
except ValueError:
self.send_error(400, "Bad Content-Length")
return
if length < 0:
self.send_error(400, "Negative Content-Length")
return
if length > MAX_BODY_BYTES:
self.send_error(413, "Request body too large")
return
body = self.rfile.read(length) if length else b""
proc = subprocess.run(
["git", "http-backend"],
input=body,
env=env,
capture_output=True,
check=False,
)
self._write_cgi_response(proc.stdout)
def _repo_dir(self, path: str) -> Path | None:
root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve()
relative = path.lstrip("/").split(".git", 1)[0] + ".git"
candidate = (root / relative).resolve()
if root not in (candidate, *candidate.parents):
return None
if not candidate.is_dir():
return None
return candidate
@staticmethod
def _is_upload_pack(path: str, query: str) -> bool:
if path.endswith("/git-upload-pack"):
return True
if path.endswith("/info/refs"):
return any(
pair == "service=git-upload-pack"
for pair in query.split("&")
)
return False
def _write_cgi_response(self, raw: bytes) -> None:
head, sep, body = raw.partition(b"\r\n\r\n")
line_sep = b"\r\n"
if not sep:
head, sep, body = raw.partition(b"\n\n")
line_sep = b"\n"
status = 200
headers: list[tuple[str, str]] = []
for line in head.split(line_sep):
if not line:
continue
key, _, value = line.decode("latin1").partition(":")
value = value.strip()
if key.lower() == "status":
status = int(value.split()[0])
else:
headers.append((key, value))
self.send_response(status)
for key, value in headers:
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt: str, *args: object) -> None:
sys.stdout.write(fmt % args + "\n")
sys.stdout.flush()
def main() -> int:
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n")
sys.stdout.flush()
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())
-36
View File
@@ -1,36 +0,0 @@
"""Tiny logging wrappers. All output goes to stderr."""
from __future__ import annotations
import sys
from typing import NoReturn
def info(msg: str) -> None:
print(f"bot-bottle: {msg}", file=sys.stderr)
def warn(msg: str) -> None:
print(f"bot-bottle: warning: {msg}", file=sys.stderr)
def error(msg: str) -> None:
print(f"bot-bottle: error: {msg}", file=sys.stderr)
class Die(SystemExit):
"""Raised by die() so callers (and tests) can distinguish a deliberate
fatal exit from an unrelated SystemExit.
Carries the human-facing message so a caller that suppressed stderr
— e.g. the curses dashboard, whose alternate screen is wiped when the
terminal is restored — can re-surface the reason after the fact."""
def __init__(self, code: int = 1, message: str = "") -> None:
super().__init__(code)
self.message = message
def die(msg: str) -> NoReturn:
error(msg)
raise Die(1, msg)
-386
View File
@@ -1,386 +0,0 @@
"""Manifest dataclasses (PRD 0011 layout).
Reads the per-file manifest tree:
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
$HOME/.bot-bottle/agents/<name>.md — home-resident agents
$CWD/.bot-bottle/agents/<name>.md — cwd-supplied agents
Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is
the system prompt, for bottles the body is human documentation
(ignored by the parser).
Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... }
git-gate: # optional (PRD 0047)
user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, path_allowlist, auth, role, pipelock
# pipelock: { tls_passthrough: <bool>, ssrf_ip_allowlist: [<cidr>, ...] }
supervise: <bool> # optional
Agent schema (frontmatter):
bottle: <bottle-name> # required
skills: [ <skill-name>, ... ] # optional
git-gate:
user: { name: <str>, email: <str> } # optional; overlays bottle
# Claude Code subagent passthrough fields — accepted, ignored:
name, description, model, color, memory
The agent file's Markdown body is the system prompt (stripped).
Unknown top-level frontmatter keys raise ManifestError with a hint.
Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
warn at load time and contributes nothing. The trust boundary is
expressed as filesystem layout rather than resolver logic.
Validation runs once at load. Manifest.from_json_obj is preserved
as a programmatic entry point (used by tests) that takes a dict
with the same field names — useful for building manifests without
on-disk files.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Mapping
from .manifest_util import ManifestError, as_json_object
from .manifest_agent import Agent, AgentProvider
from .manifest_egress import (
EGRESS_AUTH_SCHEMES,
EgressConfig,
EgressRoute,
PipelockRoutePolicy,
validate_egress_routes,
)
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module.
__all__ = [
"ManifestError",
"GitEntry",
"GitUser",
"AgentProvider",
"EGRESS_AUTH_SCHEMES",
"PipelockRoutePolicy",
"EgressRoute",
"EgressConfig",
"Agent",
"Bottle",
"Manifest",
]
def _empty_str_dict() -> dict[str, str]:
return {}
def _section_dict(value: object, label: str) -> dict[str, object]:
"""Like as_json_object but treats absent/null as an empty section."""
if value is None:
return {}
return as_json_object(value, label)
@dataclass(frozen=True)
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider)
git: tuple[GitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git-gate.user:` in the manifest skip the
# `git config --global` step entirely. A bottle can declare a user
# identity without any git-gate.repos upstreams, and vice versa.
git_user: GitUser = field(default_factory=GitUser)
egress: EgressConfig = field(default_factory=EgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes three
# MCP tools to the agent (cred-proxy-block, pipelock-block,
# capability-block; the cred-proxy-block tool is renamed and
# retargeted at egress in PRD 0017 chunk 3) plus mounts the
# current-config dir read-only into the agent at /etc/bot-bottle/
# current-config. False (the default) skips the sidecar and mount.
supervise: bool = False
@classmethod
def from_dict(cls, name: str, raw: object) -> "Bottle":
d = as_json_object(raw, f"bottle '{name}'")
if "runtime" in d:
raise ManifestError(
f"bottle '{name}' has a 'runtime' field, which is no longer "
f"supported. gVisor (runsc) is now auto-detected by the "
f"backend; remove the 'runtime' field from the bottle "
f"definition."
)
if "ssh" in d:
raise ManifestError(
f"bottle '{name}' has an 'ssh' field, which has been removed "
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
f"holds the credential and gitleaks-scans pushes."
)
if "git" in d:
raise ManifestError(
f"bottle '{name}' uses 'git' which has been replaced by "
f"'git-gate' (PRD 0047). Move git.user → git-gate.user "
f"and git.remotes → git-gate.repos (fields: url, identity, host_key)."
)
if "git_user" in d:
raise ManifestError(
f"bottle '{name}' has a 'git_user' field, which has been "
f"removed. Move it under 'git-gate.user'."
)
unknown = set(d.keys()) - BOTTLE_KEYS
if unknown:
allowed = ", ".join(sorted(BOTTLE_KEYS))
raise ManifestError(
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
f"allowed keys are {allowed}."
)
env: dict[str, str] = {}
env_raw = d.get("env")
if env_raw is not None:
env_dict = as_json_object(env_raw, f"bottle '{name}' env")
for var, value in env_dict.items():
if not isinstance(value, str):
raise ManifestError(
f"env entry {var} in bottle '{name}' must be a JSON string "
f"(was {type(value).__name__}). Use \"?<message>\" for prompt-at-runtime."
)
env[var] = value
git: tuple[GitEntry, ...] = ()
git_user = GitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
git, git_user = parse_git_gate_config(name, git_raw)
agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d
else AgentProvider()
)
egress = (
EgressConfig.from_dict(name, d["egress"])
if "egress" in d
else EgressConfig()
)
supervise_raw = d.get("supervise", False)
if not isinstance(supervise_raw, bool):
raise ManifestError(
f"bottle '{name}' supervise must be a boolean "
f"(was {type(supervise_raw).__name__})"
)
return cls(
env=env, agent_provider=agent_provider, git=git,
git_user=git_user, egress=egress, supervise=supervise_raw,
)
@dataclass(frozen=True)
class Manifest:
bottles: Mapping[str, Bottle]
agents: Mapping[str, Agent]
@classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
"""Walk the per-file manifest tree and build a Manifest.
Layout (PRD 0011):
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
$HOME/.bot-bottle/agents/<name>.md — home agents
$CWD/.bot-bottle/agents/<name>.md — cwd agents
Cwd agents merge into the home agents on the same name
(cwd wins). A bottles/ subdir under $CWD is logged as a
warning and ignored — the filesystem layout IS the trust
boundary.
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
returns an empty manifest instead of dying. This is for
passive UI surfaces like the dashboard, which can still
monitor already-running agents without launch config.
If `bot-bottle.json` exists alongside a missing
`.bot-bottle/` directory at either side, dies with a
clear pointer at the README's manifest section — the
manifest format changed in PRD 0011 and we don't silently
fall back."""
home_dir = Path(os.environ["HOME"])
cwd_dir = Path(cwd)
home_md = home_dir / ".bot-bottle"
cwd_md = cwd_dir / ".bot-bottle"
from .manifest_loader import check_stale_json
check_stale_json(home_dir, home_md, "$HOME")
if cwd_dir.resolve() != home_dir.resolve():
check_stale_json(cwd_dir, cwd_md, "$CWD")
if not home_md.is_dir():
if missing_ok:
return cls.from_json_obj({"bottles": {}, "agents": {}})
raise ManifestError(
f"no manifest found: {home_md} does not exist. "
f"See README.md for the per-file Markdown layout "
f"(PRD 0011)."
)
# When CWD == HOME (running from $HOME directly), pass the
# same dir for both — _load_md_dirs will dedupe.
cwd_md_arg = cwd_md if cwd_md.is_dir() and cwd_dir.resolve() != home_dir.resolve() else None
return cls.from_md_dirs(home_md, cwd_md_arg)
@classmethod
def from_md_dirs(
cls,
home_dir: Path,
cwd_dir: Path | None,
) -> "Manifest":
"""Programmatic entry point. Loads bottles from
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
name collision. A `bottles/` subdir under `cwd_dir` is
logged as a warning and ignored.
Used by tests to build a Manifest from fixture directories
without touching `os.environ`."""
bottles_dir = home_dir / "bottles"
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
bottles = load_bottles_from_dir(bottles_dir)
bottle_names = set(bottles.keys())
agents_dir = home_dir / "agents"
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
files = sorted(stale_bottles.glob("*.md"))
if files:
names = ", ".join(p.name for p in files)
from .log import warn
warn(
f"ignoring bottle file(s) under "
f"{stale_bottles}: {names}. Bottles can only "
f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete."
)
cwd_agents_dir = cwd_dir / "agents"
cwd_agents = load_agents_from_dir(
cwd_agents_dir, bottle_names, source="$CWD"
)
agents = {**agents, **cwd_agents}
return cls(bottles=bottles, agents=agents)
@classmethod
def from_json_obj(cls, obj: object) -> "Manifest":
"""Validate and build a Manifest from a raw JSON-like dict."""
d = as_json_object(obj, "manifest")
raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'")
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
# Coerce each bottle's raw to dict[str, object] so the
# PRD 0025 resolver can apply extends-merge rules
# consistently with the md-loader path.
raw_bottles: dict[str, dict[str, object]] = {}
for n, b in raw_bottles_obj.items():
raw_bottles[n] = as_json_object(b, f"bottle '{n}'")
from .manifest_extends import resolve_bottles
bottles = resolve_bottles(raw_bottles)
bottle_names = set(bottles.keys())
agents: dict[str, Agent] = {
n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
}
return cls(bottles=bottles, agents=agents)
def has_agent(self, name: str) -> bool:
return name in self.agents
def require_agent(self, name: str) -> None:
if self.has_agent(name):
return
available = ", ".join(self.agents.keys())
if available:
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json. Available: {available}")
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json (manifest is empty).")
def has_bottle(self, name: str) -> bool:
return name in self.bottles
def require_bottle(self, name: str) -> None:
if self.has_bottle(name):
return
available = ", ".join(self.bottles.keys())
if available:
raise ManifestError(
f"bottle '{name}' not defined in bot-bottle.json. "
f"Available bottles: {available}"
)
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> GitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
(`_merge_bottles`)."""
agent = self.agents[agent_name]
base = self.bottles[agent.bottle].git_user
over = agent.git_user
if over.is_empty():
return base
return GitUser(
name=over.name or base.name,
email=over.email or base.email,
)
def bottle_for(self, agent_name: str) -> Bottle:
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
provisioners pick up the merged identity unchanged."""
bottle = self.bottles[self.agents[agent_name].bottle]
merged = self._effective_git_user(agent_name)
if merged == bottle.git_user:
return bottle
return replace(bottle, git_user=merged)
def git_identity_summary(self, agent_name: str) -> str | None:
"""One-line effective git identity with per-field provenance
for launch summaries, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agents[agent_name].git_user
merged = self._effective_git_user(agent_name)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
-166
View File
@@ -1,166 +0,0 @@
"""Agent configuration manifest dataclasses."""
from __future__ import annotations
from dataclasses import dataclass
from typing import cast
from .agent_provider import PROVIDER_TEMPLATES
from .manifest_util import ManifestError, as_json_object
from .manifest_git import GitUser
from .manifest_schema import AGENT_MODEL_KEYS
@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.
`auth_token` names the host env var that holds the provider's OAuth
token (Claude only). The provisioner injects a provider-owned egress
route for api.anthropic.com that re-injects this token as the Bearer
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
so the Claude Code CLI starts.
`forward_host_credentials` forwards the host Codex auth token into
the egress sidecar (Codex only).
"""
template: str = "claude"
dockerfile: str = ""
auth_token: str = ""
forward_host_credentials: bool = False
@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", "auth_token", "forward_host_credentials"}:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
f"allowed: template, dockerfile, auth_token, forward_host_credentials"
)
template = d.get("template", "claude")
if not isinstance(template, str) or not template:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.template must be a "
f"non-empty string"
)
if template not in PROVIDER_TEMPLATES:
raise ManifestError(
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):
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
f"string (was {type(dockerfile).__name__})"
)
auth_token = d.get("auth_token", "")
if not isinstance(auth_token, str):
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token must be a "
f"string (was {type(auth_token).__name__})"
)
if auth_token and template != "claude":
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token is only "
f"supported for template 'claude'"
)
forward_host_credentials = d.get("forward_host_credentials", False)
if not isinstance(forward_host_credentials, bool):
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"must be a boolean (was {type(forward_host_credentials).__name__})"
)
if forward_host_credentials and template != "codex":
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"is currently only supported for template 'codex'"
)
return cls(
template=template,
dockerfile=dockerfile,
auth_token=auth_token,
forward_host_credentials=forward_host_credentials,
)
@dataclass(frozen=True)
class Agent:
bottle: str
skills: tuple[str, ...] = ()
prompt: str = ""
# Per-agent git identity (issue #94). Overlays the referenced
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust.
git_user: GitUser = GitUser()
@classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
d = as_json_object(raw, f"agent '{name}'")
unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown:
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
raise ManifestError(
f"agent '{name}' has unknown key(s) {sorted(unknown)}; "
f"allowed keys are {allowed}."
)
bottle = d.get("bottle")
if not isinstance(bottle, str) or not bottle:
raise ManifestError(f"agent '{name}' must declare a 'bottle' field naming a defined bottle")
if bottle not in bottle_names:
available = ", ".join(sorted(bottle_names)) or "(none defined)"
raise ManifestError(
f"agent '{name}' references bottle '{bottle}', which is not defined. "
f"Available: {available}"
)
skills: tuple[str, ...] = ()
skills_raw = d.get("skills")
if skills_raw is not None:
if not isinstance(skills_raw, list):
raise ManifestError(f"agent '{name}' skills must be an array (was {type(skills_raw).__name__})")
collected: list[str] = []
skills_list = cast(list[object], skills_raw)
for i, skill in enumerate(skills_list):
if not isinstance(skill, str):
raise ManifestError(
f"agent '{name}' skills[{i}] must be a string "
f"(was {type(skill).__name__})"
)
collected.append(skill)
skills = tuple(collected)
prompt_raw = d.get("prompt")
if prompt_raw is None:
prompt = ""
elif isinstance(prompt_raw, str):
prompt = prompt_raw
else:
raise ManifestError(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})")
# git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = GitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
for k in gd.keys():
if k != "user":
raise ManifestError(
f"agent '{name}' git-gate.{k} is not allowed at the "
f"agent level; only git-gate.user (name/email) may be "
f"set on an agent. git-gate.repos is bottle-only "
f"(it carries credentials and host trust)."
)
if "user" in gd:
git_user = GitUser.from_dict(name, gd["user"])
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
-286
View File
@@ -1,286 +0,0 @@
"""Egress routing manifest dataclasses and helpers."""
from __future__ import annotations
import ipaddress
from dataclasses import dataclass, field
from typing import cast
from .manifest_util import ManifestError, as_json_object
# Auth schemes for the egress route's optional `auth` block.
# Same values cred-proxy accepts today; `token` sidesteps the Gitea
# token-not-Bearer quirk (go-gitea/gitea#16734).
EGRESS_AUTH_SCHEMES = ("Bearer", "token")
def validate_egress_routes(
bottle_name: str,
routes: tuple[EgressRoute, ...],
) -> None:
"""Cross-validation for `bottle.egress.routes`: hosts must be unique.
The proxy matches by exact-host (v1); duplicate hosts leave the
route choice ambiguous so we reject them up front.
No cross-validation against `bottle.git-gate.repos` is performed.
git-gate (SSH push/fetch) and egress (HTTPS) broker different
protocols; declaring both for the same host is a legitimate dev
setup."""
seen_hosts: dict[str, None] = {}
for r in routes:
key = r.Host.lower()
if key in seen_hosts:
raise ManifestError(
f"bottle '{bottle_name}' egress.routes has duplicate host "
f"{r.Host!r}; each host must be unique on the proxy."
)
seen_hosts[key] = None
@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"):
raise ManifestError(
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):
raise ManifestError(
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):
raise ManifestError(
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:
raise ManifestError(
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:
raise ManifestError(
f"{label}.ssrf_ip_allowlist[{j}] must be an IP address "
f"or CIDR (was {item!r}): {e}"
)
ssrf_ip_allowlist.append(item)
return cls(
TlsPassthrough=tls_passthrough_raw,
SsrfIpAllowlist=tuple(ssrf_ip_allowlist),
)
@dataclass(frozen=True)
class EgressRoute:
"""One route on the per-bottle egress sidecar (PRD 0017).
`Host` matches the request's hostname (case-insensitive). The
optional `PathAllowlist` constrains the URL path to a set of
prefixes; empty tuple means no path-level filtering. The optional
`AuthScheme` / `TokenRef` pair drives credential injection:
when set, the proxy strips any inbound Authorization and injects
`<AuthScheme> <value-of-host-env-named-by-TokenRef>`. When the
manifest's `auth` block is omitted both fields are empty strings —
no Authorization is written, no token forwarded.
`Role` is reserved for future use; all role strings are currently
rejected by the validator.
Validation rules (enforced in `from_dict`):
- `host` required, non-empty.
- `path_allowlist` optional, list of absolute path prefixes.
- `auth` optional. If present, MUST carry both `scheme` and
`token_ref` as non-empty strings; an empty `auth: {}` is an
error rather than a synonym for "no auth" (omit `auth` for
that case).
- `role` optional, reserved — any non-empty value is rejected.
"""
Host: str
PathAllowlist: tuple[str, ...] = ()
AuthScheme: str = ""
TokenRef: str = ""
Role: tuple[str, ...] = ()
Pipelock: PipelockRoutePolicy = field(default_factory=PipelockRoutePolicy)
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = as_json_object(raw, label)
host = d.get("host")
if not isinstance(host, str) or not host:
raise ManifestError(f"{label} missing required string field 'host'")
path_allow_raw = d.get("path_allowlist")
prefixes: tuple[str, ...] = ()
if path_allow_raw is not None:
if not isinstance(path_allow_raw, list):
raise ManifestError(
f"{label} path_allowlist must be an array "
f"(was {type(path_allow_raw).__name__})"
)
path_list = cast(list[object], path_allow_raw)
collected: list[str] = []
for j, p in enumerate(path_list):
if not isinstance(p, str):
raise ManifestError(
f"{label} path_allowlist[{j}] must be a string "
f"(was {type(p).__name__})"
)
if not p.startswith("/"):
raise ManifestError(
f"{label} path_allowlist[{j}] {p!r} must be an "
f"absolute path prefix starting with '/'"
)
collected.append(p)
prefixes = tuple(collected)
auth_scheme = ""
token_ref = ""
if "auth" in d:
auth_raw = d.get("auth")
auth_d = as_json_object(auth_raw, f"{label} auth")
if not auth_d:
raise ManifestError(
f"{label} auth is empty ({{}}); omit the 'auth' key "
f"entirely if this route is unauthenticated. Otherwise "
f"both 'scheme' and 'token_ref' are required."
)
auth_scheme_raw = auth_d.get("scheme")
if not isinstance(auth_scheme_raw, str) or not auth_scheme_raw:
raise ManifestError(
f"{label} auth.scheme is required when 'auth' is set "
f"(non-empty string)"
)
if auth_scheme_raw not in EGRESS_AUTH_SCHEMES:
raise ManifestError(
f"{label} auth.scheme {auth_scheme_raw!r} is not one of "
f"{', '.join(EGRESS_AUTH_SCHEMES)}"
)
token_ref_raw = auth_d.get("token_ref")
if not isinstance(token_ref_raw, str) or not token_ref_raw:
raise ManifestError(
f"{label} auth.token_ref is required when 'auth' is set "
f"(name of the host env var holding the token value)"
)
for k in auth_d:
if k not in ("scheme", "token_ref"):
raise ManifestError(
f"{label} auth has unknown key {k!r}; "
f"only 'scheme' and 'token_ref' are accepted"
)
auth_scheme = auth_scheme_raw
token_ref = token_ref_raw
role_raw = d.get("role")
roles: tuple[str, ...] = ()
if role_raw is None:
roles = ()
elif isinstance(role_raw, str):
roles = (role_raw,)
elif isinstance(role_raw, list):
role_list = cast(list[object], role_raw)
collected_roles: list[str] = []
for r in role_list:
if not isinstance(r, str):
raise ManifestError(f"{label} role items must be strings (got {type(r).__name__})")
collected_roles.append(r)
roles = tuple(collected_roles)
else:
raise ManifestError(
f"{label} role must be a string or a list of strings "
f"(was {type(role_raw).__name__})"
)
if roles:
raise ManifestError(
f"{label} role {roles[0]!r} is not accepted; "
f"the 'role' field is reserved for future use"
)
pipelock = (
PipelockRoutePolicy.from_dict(bottle_name, idx, d["pipelock"])
if "pipelock" in d
else PipelockRoutePolicy()
)
for k in d:
if k not in ("host", "path_allowlist", "auth", "role", "pipelock"):
raise ManifestError(
f"{label} has unknown key {k!r}; accepted keys are "
f"'host', 'path_allowlist', 'auth', 'role', 'pipelock'"
)
return cls(
Host=host,
PathAllowlist=prefixes,
AuthScheme=auth_scheme,
TokenRef=token_ref,
Role=roles,
Pipelock=pipelock,
)
@dataclass(frozen=True)
class EgressConfig:
"""Per-bottle egress configuration. Today this is just the
route table; the nesting under `egress:` leaves room for
per-bottle proxy settings (port override, log level, etc.) in
follow-ups."""
routes: tuple[EgressRoute, ...] = ()
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
d = as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = ()
if routes_raw is not None:
if not isinstance(routes_raw, list):
raise ManifestError(
f"bottle '{bottle_name}' egress.routes must be an array "
f"(was {type(routes_raw).__name__})"
)
routes_list = cast(list[object], routes_raw)
routes = tuple(
EgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list)
)
validate_egress_routes(bottle_name, routes)
for k in d:
if k != "routes":
raise ManifestError(
f"bottle '{bottle_name}' egress has unknown key {k!r}; "
f"only 'routes' is accepted"
)
return cls(routes=routes)
-142
View File
@@ -1,142 +0,0 @@
"""Internal bottle `extends:` resolution for manifests."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .manifest import Bottle, GitEntry
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
"""Apply `extends:` chains and return resolved Bottle objects."""
cache: dict[str, Bottle] = {}
for name in raws:
if name not in cache:
_resolve_one_bottle(name, raws, cache, ())
return cache
def _resolve_one_bottle(
name: str,
raws: dict[str, dict[str, object]],
cache: dict[str, Bottle],
seen: tuple[str, ...],
) -> Bottle:
from .manifest import Bottle, ManifestError
if name in cache:
return cache[name]
if name in seen:
chain = " -> ".join(seen + (name,))
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
raw = raws[name]
parent_name_raw = raw.get("extends")
# Strip `extends:` before passing to Bottle.from_dict so it
# is not accidentally treated as a real Bottle field by future
# schema additions. It is only meaningful here.
child_raw = {k: v for k, v in raw.items() if k != "extends"}
if parent_name_raw is None:
bottle = Bottle.from_dict(name, child_raw)
cache[name] = bottle
return bottle
if not isinstance(parent_name_raw, str):
raise ManifestError(
f"bottle '{name}' extends must be a string "
f"(was {type(parent_name_raw).__name__})"
)
parent_name: str = parent_name_raw
if parent_name == name:
raise ManifestError(
f"bottle '{name}' extends itself; remove the "
f"self-reference"
)
if parent_name not in raws:
avail = ", ".join(sorted(raws.keys())) or "(none)"
raise ManifestError(
f"bottle '{name}' extends '{parent_name}' which is not "
f"defined. Available bottles: {avail}"
)
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
bottle = _merge_bottles(parent, child_raw, name)
cache[name] = bottle
return bottle
def _merge_bottles(
parent: Bottle,
child_raw: dict[str, object],
name: str,
) -> Bottle:
"""Apply PRD 0025 merge rules."""
from .manifest import Bottle, GitUser
from .manifest_egress import validate_egress_routes
# Parse the child's declared fields into a Bottle (with the
# usual defaults for anything missing). Validation runs the same
# way it would for a leaf bottle: typos / wrong types die here.
child = Bottle.from_dict(name, child_raw)
# env: dict merge, child wins on collision.
merged_env = {**parent.env, **child.env}
# git-gate.user: per-field overlay. Each non-empty field on child
# wins; empties fall through to parent. The default GitUser()
# is two empty strings, so a child that omits git-gate.user
# inherits the parent's user verbatim.
merged_git_user = GitUser(
name=child.git_user.name or parent.git_user.name,
email=child.git_user.email or parent.git_user.email,
)
# git-gate.repos: missing means inherit; an explicit empty object
# clears; otherwise parent and child merge by UpstreamHost with
# child entries replacing duplicate hosts.
if _child_declares_git_gate_repos(child_raw):
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
else:
merged_git = parent.git
# Presence-driven full-replace for the remaining list-valued +
# scalar fields.
merged_egress = child.egress if "egress" in child_raw else parent.egress
merged_agent_provider = (
child.agent_provider
if "agent_provider" in child_raw
else parent.agent_provider
)
merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise
)
validate_egress_routes(name, merged_egress.routes)
return Bottle(
env=merged_env,
agent_provider=merged_agent_provider,
git=merged_git,
git_user=merged_git_user,
egress=merged_egress,
supervise=merged_supervise,
)
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
from .manifest_util import as_json_object
git_raw = child_raw.get("git-gate")
if git_raw is None:
return False
git_obj = as_json_object(git_raw, "child git-gate")
return "repos" in git_obj
def _merge_git_remotes(
parent: tuple[GitEntry, ...],
child: tuple[GitEntry, ...],
) -> tuple[GitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
return tuple(by_host.values())
-222
View File
@@ -1,222 +0,0 @@
"""Git-related manifest dataclasses and helpers."""
from __future__ import annotations
import re
from dataclasses import dataclass
from .manifest_util import ManifestError, as_json_object
# Shell-safe characters for git-gate repo names. Names are embedded in
# the generated entrypoint shell script (shlex.quote is the primary
# defence; this regex is belt-and-suspenders and documents intent).
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _opt_str(value: object, label: str) -> str:
if value is None:
return ""
if not isinstance(value, str):
raise ManifestError(f"{label} must be a string (was {type(value).__name__})")
return value
def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
"""Parse `ssh://user@host[:port]/path` into (user, host, port, path).
Dies if `url` doesn't match the ssh:// shape v1 supports. Default
port is 22 (matches OpenSSH)."""
if not url.startswith("ssh://"):
raise ManifestError(f"{label} must be an ssh:// URL (was {url!r})")
rest = url[len("ssh://"):]
if "@" not in rest:
raise ManifestError(f"{label} must include a user (e.g. ssh://git@host/path.git); was {url!r}")
user, _, hostpart = rest.partition("@")
if not user:
raise ManifestError(f"{label} user is empty in {url!r}")
if "/" not in hostpart:
raise ManifestError(f"{label} must include a path (e.g. ssh://git@host/path.git); was {url!r}")
hostport, _, path = hostpart.partition("/")
if not path:
raise ManifestError(f"{label} path is empty in {url!r}")
if ":" in hostport:
host, _, port = hostport.partition(":")
if not port.isdigit():
raise ManifestError(f"{label} port must be numeric in {url!r}")
else:
host = hostport
port = "22"
if not host:
raise ManifestError(f"{label} host is empty in {url!r}")
return (user, host, port, path)
def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
seen: dict[str, None] = {}
for g in git:
if g.Name in seen:
raise ManifestError(
f"bottle '{bottle_name}' git-gate.repos has duplicate name '{g.Name}'; "
f"each entry maps to a distinct bare repo on the gate."
)
seen[g.Name] = None
@dataclass(frozen=True)
class GitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
talk to. `Upstream` is the real remote URL the agent would push to
if there were no gate; the gate hosts a bare repo at /git/<Name>.git
and `IdentityFile` is the SSH key the gate uses to push that repo
upstream after gitleaks passes. The agent itself never holds the
upstream credential.
The Upstream URL is parsed once at construction and the pieces are
stashed in the `Upstream*` fields so the git-gate render step
doesn't have to re-parse.
Manifest source: `git-gate.repos.<Name>` (PRD 0047). The YAML keys
are `url`, `identity`, and `host_key`; the internal field names are
stable across that rename."""
Name: str
Upstream: str
IdentityFile: str
KnownHostKey: str = ""
RemoteKey: str = ""
UpstreamUser: str = ""
UpstreamHost: str = ""
UpstreamPort: str = ""
UpstreamPath: str = ""
@classmethod
def from_repos_entry(
cls, bottle_name: str, repo_name: str, raw: object
) -> "GitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), `identity` (required),
`host_key` (optional). The repo_name becomes `Name`."""
if not repo_name:
raise ManifestError(
f"bottle '{bottle_name}' git-gate.repos has an empty key"
)
if not _GIT_NAME_RE.match(repo_name):
raise ManifestError(
f"bottle '{bottle_name}' git-gate.repos name {repo_name!r} is invalid; "
f"allowed characters: A-Z a-z 0-9 . _ -"
)
label = f"git-gate.repos[{repo_name!r}]"
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
for k in d:
if k not in {"url", "identity", "host_key"}:
raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, identity, host_key"
)
upstream = d.get("url")
if not isinstance(upstream, str) or not upstream:
raise ManifestError(
f"bottle '{bottle_name}' {label} missing required string field 'url'"
)
ident = d.get("identity")
if not isinstance(ident, str) or not ident:
raise ManifestError(
f"bottle '{bottle_name}' {label} missing required string field 'identity'"
)
khk = _opt_str(
d.get("host_key"),
f"bottle '{bottle_name}' {label} host_key",
)
user, host, port, path = parse_git_upstream(
upstream, f"bottle '{bottle_name}' {label} url"
)
return cls(
Name=repo_name,
Upstream=upstream,
IdentityFile=ident,
KnownHostKey=khk,
RemoteKey=host,
UpstreamUser=user,
UpstreamHost=host,
UpstreamPort=port,
UpstreamPath=path,
)
@dataclass(frozen=True)
class GitUser:
"""Per-bottle `git config --global user.name` / `user.email`
pair (issue #86). The agent's commits inside the bottle are
attributed to this identity rather than the agent image's
image-baked default (no user, or whatever the image dropped
in). Either or both fields can be set independently.
`from_dict` is forgiving on shape (a single missing field is
fine — we just skip that config line at provisioning) but
strict on types (string-or-die)."""
name: str = ""
email: str = ""
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
for k in d.keys():
if k not in {"name", "email"}:
raise ManifestError(
f"bottle '{bottle_name}' git-gate.user has unknown key {k!r}; "
f"allowed: name, email"
)
name = d.get("name", "")
email = d.get("email", "")
if not isinstance(name, str):
raise ManifestError(
f"bottle '{bottle_name}' git-gate.user.name must be a string "
f"(was {type(name).__name__})"
)
if not isinstance(email, str):
raise ManifestError(
f"bottle '{bottle_name}' git-gate.user.email must be a string "
f"(was {type(email).__name__})"
)
if not name and not email:
raise ManifestError(
f"bottle '{bottle_name}' git-gate.user is set but neither "
f"name nor email is non-empty; remove the block or "
f"fill at least one field."
)
return cls(name=name, email=email)
def is_empty(self) -> bool:
return not self.name and not self.email
def parse_git_gate_config(
bottle_name: str,
raw: object,
) -> tuple[tuple[GitEntry, ...], GitUser]:
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
for k in d.keys():
if k not in {"user", "repos"}:
raise ManifestError(
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
f"allowed: user, repos"
)
git_user = (
GitUser.from_dict(bottle_name, d["user"])
if "user" in d
else GitUser()
)
git: tuple[GitEntry, ...] = ()
repos_raw = d.get("repos")
if repos_raw is not None:
repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos")
git = tuple(
GitEntry.from_repos_entry(bottle_name, name, entry)
for name, entry in repos.items()
)
validate_unique_git_names(bottle_name, git)
return git, git_user
-105
View File
@@ -1,105 +0,0 @@
"""Internal per-file Markdown manifest loader."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from .log import warn
from .manifest_schema import (
entity_name_from_path,
validate_agent_frontmatter_keys,
validate_bottle_frontmatter_keys,
)
from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING:
from .manifest import Agent, Bottle
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
not. The manifest format changed in PRD 0011 and we do not want
to silently leave the JSON content unused."""
from .manifest import ManifestError
legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists():
raise ManifestError(
f"found {legacy} but {md_dir} does not exist. The manifest "
f"format changed in PRD 0011 — rewrite the JSON content "
f"as per-file Markdown under {md_dir}/bottles/ and "
f"{md_dir}/agents/. See README.md for the schema. "
f"({label})"
)
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
raws: dict[str, dict[str, object]] = {}
if not bottles_dir.is_dir():
return {}
for path in sorted(bottles_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}")
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}")
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str,
) -> dict[str, Agent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import Agent, ManifestError
out: dict[str, Agent] = {}
if not agents_dir.is_dir():
return out
for path in sorted(agents_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}")
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}")
validate_agent_frontmatter_keys(path, fm.keys())
# Build the dict Agent.from_dict expects. The body becomes
# prompt; Claude Code passthrough fields stay in fm and get
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = Agent.from_dict(name, agent_dict, bottle_names)
return out
-70
View File
@@ -1,70 +0,0 @@
"""Internal manifest schema policy helpers."""
from __future__ import annotations
import re
from pathlib import Path
# Filename-as-key uses kebab-case ASCII. The first character is a
# letter so we don't conflict with hidden files / Markdown special
# names (`.md`, `_template.md`, etc.). Filenames that fail this
# pattern are skipped with a warning rather than crashing the load.
_FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# Frontmatter keys we accept on each entity. Anything not in these
# sets dies with a "did you mean" pointer: typos should not silently
# ghost into an empty config.
BOTTLE_KEYS = frozenset(
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"}
)
AGENT_KEYS_REQUIRED = frozenset({"bottle"})
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"})
# Claude Code subagent fields bot-bottle ignores at launch but does
# not reject. This lets the same file double as
# `~/.claude/agents/*.md` without modification.
CLAUDE_CODE_AGENT_PASSTHROUGH_KEYS = frozenset({
"name", "description", "model", "color", "memory",
})
AGENT_KEYS = (
AGENT_KEYS_REQUIRED | AGENT_KEYS_OPTIONAL | CLAUDE_CODE_AGENT_PASSTHROUGH_KEYS
)
AGENT_MODEL_KEYS = AGENT_KEYS | frozenset({"prompt"})
def entity_name_from_path(path: Path) -> str | None:
"""Return the entity name implied by the filename, or None if the
filename does not fit the [a-z][a-z0-9-]* convention."""
if path.suffix != ".md":
return None
stem = path.stem
if not _FILENAME_RX.match(stem):
return None
return stem
def validate_bottle_frontmatter_keys(path: Path, keys: object) -> None:
_validate_frontmatter_keys("bottle", path, keys, BOTTLE_KEYS)
def validate_agent_frontmatter_keys(path: Path, keys: object) -> None:
_validate_frontmatter_keys("agent", path, keys, AGENT_KEYS)
def _validate_frontmatter_keys(
kind: str,
path: Path,
keys: object,
allowed_keys: frozenset[str],
) -> None:
from .manifest_util import ManifestError
key_set = set(keys)
unknown = key_set - allowed_keys
if unknown:
allowed = ", ".join(sorted(allowed_keys))
raise ManifestError(
f"{kind} file {path}: unknown frontmatter key(s) "
f"{sorted(unknown)}; allowed keys are {allowed}."
)
-24
View File
@@ -1,24 +0,0 @@
"""Shared manifest primitives used by all manifest sub-modules."""
from __future__ import annotations
from typing import cast
class ManifestError(Exception):
"""A manifest file (or the manifest tree) is invalid."""
def as_json_object(value: object, label: str) -> dict[str, object]:
"""Assert that `value` is a JSON object (str-keyed dict) and return
a view typed as `dict[str, object]` so downstream `.get(...)` calls
have a typed surface."""
if not isinstance(value, dict):
raise ManifestError(f"{label} must be a JSON object (was {type(value).__name__})")
items = cast(dict[object, object], value)
out: dict[str, object] = {}
for k, v in items.items():
if not isinstance(k, str):
raise ManifestError(f"{label} keys must be strings (found {type(k).__name__})")
out[k] = v
return out
-52
View File
@@ -1,52 +0,0 @@
"""Backend-neutral plan for porting the operator workspace."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
WORKSPACE_DIRNAME = "workspace"
DEFAULT_WORKSPACE_OWNER = "node:node"
DEFAULT_WORKSPACE_MODE = "755"
class WorkspaceSpec(Protocol):
copy_cwd: bool
user_cwd: str
@dataclass(frozen=True)
class WorkspacePlan:
"""Resolved workspace contract shared by all bottle backends."""
enabled: bool
host_path: Path
guest_home: str
guest_path: str
workdir: str
owner: str = DEFAULT_WORKSPACE_OWNER
mode: str = DEFAULT_WORKSPACE_MODE
copy_contents: bool = True
copy_git: bool = True
has_host_git_dir: bool = False
def workspace_plan(spec: WorkspaceSpec, *, guest_home: str) -> WorkspacePlan:
"""Resolve the in-bottle workspace path from CLI intent."""
host_path = Path(spec.user_cwd).expanduser()
if spec.copy_cwd:
guest_path = f"{guest_home.rstrip('/')}/{WORKSPACE_DIRNAME}"
workdir = guest_path
else:
guest_path = guest_home
workdir = guest_home
return WorkspacePlan(
enabled=spec.copy_cwd,
host_path=host_path,
guest_home=guest_home,
guest_path=guest_path,
workdir=workdir,
has_host_git_dir=(host_path / ".git").is_dir(),
)
+25
View File
@@ -0,0 +1,25 @@
{
"bottles": {
"demo": {
"env": {
"FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
},
"git": [
{
"Name": "foo",
"Upstream": "ssh://git@upstream.invalid/path.git",
"IdentityFile": "~/.cache/claude-bottle-demo/fake-key",
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE"
}
]
}
},
"agents": {
"demo": {
"bottle": "demo",
"skills": [],
"prompt": "You are inside claude-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
}
}
}
+1
View File
@@ -0,0 +1 @@
"""claude-bottle: Python implementation of the agent container launcher."""
@@ -25,29 +25,22 @@ 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
BOT_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the CLAUDE_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.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan from ..log import die
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
from ..manifest import GitEntry, Manifest from ..manifest import GitEntry, Manifest
from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..workspace import WorkspacePlan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
@@ -72,57 +65,15 @@ class BottleSpec:
@dataclass(frozen=True) @dataclass(frozen=True)
class BottlePlan(ABC): class BottlePlan(ABC):
"""Base output of a backend's prepare step. Concrete subclasses """Base output of a backend's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add backend-specific resolved fields.""" (e.g. DockerBottlePlan) add backend-specific resolved fields and
implement `print`."""
spec: BottleSpec spec: BottleSpec
stage_dir: Path stage_dir: Path
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan
@abstractmethod
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
del remote_control
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
env_names = visible_agent_env_names(
sorted(
set(bottle.env.keys())
| set(self.agent_provision.guest_env.keys())
),
hidden_env_names=self.agent_provision.hidden_env_names,
)
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provision.template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(spec.agent_name)
if identity:
info(f" git identity : {identity}")
git_lines = [
f"{u.name}{u.upstream_host}:{u.upstream_port}"
for u in self.git_gate_plan.upstreams
]
if git_lines:
print_multi(" git gate ", git_lines)
if self.egress_plan.routes:
egress_lines = []
for r in self.egress_plan.routes:
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines)
print(file=sys.stderr)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -179,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_agent` runs the selected agent CLI inside the bottle and `exec_claude` runs `claude` inside the bottle and blocks until the
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle 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.
@@ -189,22 +140,7 @@ class Bottle(ABC):
name: str name: str
@abstractmethod @abstractmethod
def agent_argv( def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
"""Return the host-side argv that runs the selected agent
inside the bottle. Used by `exec_agent` for foreground
handoffs and by the dashboard's tmux `respawn-pane` flow,
which needs the argv up front (it spawns claude in a tmux
pane rather than as a child of the current process).
Implementations transparently inject
`--append-system-prompt-file` when the bottle was launched
with a provisioned prompt path."""
...
@abstractmethod
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:
@@ -264,7 +200,6 @@ 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
@@ -288,20 +223,6 @@ 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,
@@ -319,10 +240,10 @@ 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 provider-specific prompt args to the agent's decide whether to add --append-system-prompt-file to claude's
argv. argv.
Default orchestration: ca prompt skills workspace git Default orchestration: ca prompt skills git
supervise. CA install runs first so the agent's trust store supervise. CA install runs first so the agent's trust store
is rebuilt before anything inside the agent makes a TLS call. is rebuilt before anything inside the agent makes a TLS call.
Subclasses typically don't override this; they implement the Subclasses typically don't override this; they implement the
@@ -335,9 +256,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
intercepted without per-tool reconfiguration.""" intercepted without per-tool reconfiguration."""
self.provision_ca(plan, target) self.provision_ca(plan, target)
prompt_path = self.provision_prompt(plan, target) prompt_path = self.provision_prompt(plan, target)
self.provision_provider_auth(plan, target)
self.provision_skills(plan, target) self.provision_skills(plan, target)
self.provision_workspace(plan, target)
self.provision_git(plan, target) self.provision_git(plan, target)
self.provision_supervise(plan, target) self.provision_supervise(plan, target)
return prompt_path return prompt_path
@@ -351,28 +270,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
backend overrides to docker-cp the cert in and run backend overrides to docker-cp the cert in and run
`update-ca-certificates`.""" `update-ca-certificates`."""
def provision_provider_auth(self, plan: PlanT, target: str) -> None:
"""Install non-secret provider auth marker files into the agent
home when a provider needs them to select the right auth mode.
The default is no-op."""
@abstractmethod @abstractmethod
def provision_prompt(self, plan: PlanT, target: str) -> str | None: def provision_prompt(self, plan: PlanT, target: str) -> str | None:
"""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
provider-specific prompt args to the agent's argv.""" --append-system-prompt-file to claude's argv."""
@abstractmethod @abstractmethod
def provision_skills(self, plan: PlanT, target: str) -> None: def provision_skills(self, plan: PlanT, target: str) -> None:
"""Copy the agent's named skills from the host into the """Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills.""" running bottle. No-op when the agent has no skills."""
def provision_workspace(self, plan: PlanT, target: str) -> None:
"""Copy the operator workspace into the running bottle when
the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch."""
@abstractmethod @abstractmethod
def provision_git(self, plan: PlanT, target: str) -> None: def provision_git(self, plan: PlanT, target: str) -> None:
"""Copy the host's cwd `.git` directory into the running """Copy the host's cwd `.git` directory into the running
@@ -437,12 +346,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. BOT_BOTTLE_BACKEND env var 2. CLAUDE_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("BOT_BOTTLE_BACKEND") or "docker" resolved = name or os.environ.get("CLAUDE_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}")
@@ -474,20 +383,14 @@ def enumerate_active_agents() -> list[ActiveAgent]:
"""All currently-running agents, across every available """All currently-running agents, across every available
backend. Used by CLI `list active` and the dashboard's agents backend. Used by CLI `list active` and the dashboard's agents
pane so neither has to know which backends exist. Skips pane so neither has to know which backends exist. Skips
backends whose `is_available()` reports False. backends whose `is_available()` reports False. Ordered by
backend name, then by whatever each backend's
Sorted by `(started_at, slug)` so the list is stable across `enumerate_active` returns."""
dashboard refresh ticks agents don't shift position while
the operator navigates with arrow keys. ISO 8601 timestamps
sort lexicographically in chronological order; `slug` is the
deterministic tiebreaker. Agents with missing metadata
(`started_at == ""`) sort first."""
out: list[ActiveAgent] = [] out: list[ActiveAgent] = []
for name in known_backend_names(): for name in known_backend_names():
if not has_backend(name): if not has_backend(name):
continue continue
out.extend(_BACKENDS[name].enumerate_active()) out.extend(_BACKENDS[name].enumerate_active())
out.sort(key=lambda a: (a.started_at, a.slug))
return out return out
@@ -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 bot_bottle.backend.docker import DockerBottleBackend` keeps `from claude_bottle.backend.docker import DockerBottleBackend` keeps
working. working.
""" """
@@ -29,13 +29,12 @@ from .bottle_plan import DockerBottlePlan
from .provision import ca as _ca from .provision import ca as _ca
from .provision import git as _git from .provision import git as _git
from .provision import prompt as _prompt from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth
from .provision import skills as _skills from .provision import skills as _skills
from .provision import supervise as _supervise_prov from .provision import supervise as _supervise_prov
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND """Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
(default).""" (default)."""
name = "docker" name = "docker"
@@ -63,9 +62,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, target)
def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None:
_provider_auth.provision_provider_auth(plan, target)
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None: def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, target)
@@ -1,11 +1,16 @@
"""DockerBottle — concrete Bottle handle yielded by DockerBottleBackend.""" """DockerBottle — concrete Bottle handle yielded by
DockerBottleBackend.launch.
Holds the container name plus the in-container prompt path so
exec_claude can transparently add --append-system-prompt-file when a
prompt was provisioned.
"""
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
@@ -17,36 +22,33 @@ 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 agent_argv( def claude_docker_argv(
self, argv: list[str], *, tty: bool = True, self, argv: list[str], *, tty: bool = True,
) -> list[str]: ) -> list[str]:
"""Return the full `docker exec` argv for running claude in
this bottle. Public so callers that want to spawn claude
somewhere other than the dashboard's foreground (e.g.,
`tmux split-window` / `tmux respawn-pane` from the dashboard
when `$TMUX` is set) can build on the same command without
duplicating the `--append-system-prompt-file` plumbing."""
full_argv = list(argv) full_argv = list(argv)
full_argv.extend( if self._prompt_path:
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv) full_argv.extend(["--append-system-prompt-file", self._prompt_path])
)
cmd = ["docker", "exec"] cmd = ["docker", "exec"]
if tty: if tty:
cmd.append("-it") cmd.append("-it")
cmd.extend([self.name, self.agent_command, *full_argv]) cmd.extend([self.name, "claude", *full_argv])
return cmd return cmd
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
return subprocess.run( return subprocess.run(
self.agent_argv(argv, tty=tty), check=False, self.claude_docker_argv(argv, tty=tty), check=False,
).returncode ).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str, *, user: str = "node") -> ExecResult:
@@ -5,12 +5,12 @@ compose ls` is the source of truth for what's running; the plan
carries the projects to `compose down`, plus three fallback buckets carries the projects to `compose down`, plus three fallback buckets
for legacy / orphan resources: for legacy / orphan resources:
- stray_containers: pre-compose `bot-bottle-*` containers not - stray_containers: pre-compose `claude-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
~/.bot-bottle/state/ that have no live compose project AND ~/.claude-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`,
@@ -0,0 +1,94 @@
"""DockerBottlePlan — concrete subclass of BottlePlan.
Carries the Docker-specific resolved fields produced by
DockerBottleBackend.prepare. The launch step consumes it without
further resolution; show_plan-style rendering is the `print` method.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from pathlib import Path
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan
from .. import BottlePlan
from ..print_util import print_multi
@dataclass(frozen=True)
class DockerBottlePlan(BottlePlan):
"""Docker-specific resolved fields produced by
DockerBottleBackend.prepare. Inherits `spec` and `stage_dir` from
BottlePlan."""
slug: str
container_name: str
container_name_pinned: bool
image: str
derived_image: str # "" -> no derived image
runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.claude-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016).
dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any
# accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path
proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
# None when bottle.supervise is False. PRD 0013 supervise sidecar
# is opt-in via the manifest's bottle.supervise field.
supervise_plan: SupervisePlan | None
use_runsc: bool
def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr — compact form
intended to fit on screen without scrolling. The full
structured shape (image, container, runtime, etc.) lives on
this dataclass for tooling that wants to introspect it."""
del remote_control # not surfaced in the compact summary
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
# The agent sees the union of literal env names (rendered into
# --env-file) and forwarded env names (`-e NAME` with the
# value arriving via subprocess env). The forwarded set holds
# the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
# interpolations from the manifest; egress holds
# upstream tokens in its own environ, so no token forwarding
# from the agent to the proxy is needed.
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
git_lines = [
f"{u.upstream_host}:{u.upstream_port}"
for u in self.git_gate_plan.upstreams
]
if git_lines:
print_multi(" git gate ", git_lines)
if self.egress_plan.routes:
egress_lines = []
for r in self.egress_plan.routes:
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines)
print(file=sys.stderr)
@@ -6,15 +6,15 @@ helper saves before teardown, and the launch metadata that lets
`cli.py resume <identity>` reconstruct a bottle's spec. State `cli.py resume <identity>` reconstruct a bottle's spec. State
lives at: lives at:
~/.bot-bottle/state/<identity>/ ~/.claude-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 (bot-bottle-rebuilt-<id>) the agent image with a per-bottle tag (claude-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 bot_bottle source files the repo root so the Dockerfile can COPY claude_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: ~/.bot-bottle/state/<identity>/... # Directory layout: ~/.claude-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
~/.bot-bottle/state/<identity>/metadata.json.""" ~/.claude-bottle/state/<identity>/metadata.json."""
identity: str identity: str
agent_name: str agent_name: str
@@ -105,10 +105,6 @@ class BottleMetadata:
# written before chunk 3 (resume / inspect should fall back to # written before chunk 3 (resume / inspect should fall back to
# deriving from identity in that case). # deriving from identity in that case).
compose_project: str = "" compose_project: str = ""
# PRD 0040: backend name ("docker" or "smolmachines"). Empty string
# for state dirs written before PRD 0040; callers default to "docker"
# for backward compatibility.
backend: str = ""
def metadata_path(identity: str) -> Path: def metadata_path(identity: str) -> Path:
@@ -116,7 +112,7 @@ def metadata_path(identity: str) -> Path:
def write_metadata(metadata: BottleMetadata) -> Path: def write_metadata(metadata: BottleMetadata) -> Path:
"""Persist `metadata` to ~/.bot-bottle/state/<identity>/metadata.json. """Persist `metadata` to ~/.claude-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)
@@ -142,14 +138,13 @@ def read_metadata(identity: str) -> BottleMetadata | None:
copy_cwd=bool(raw.get("copy_cwd", False)), copy_cwd=bool(raw.get("copy_cwd", False)),
started_at=str(raw.get("started_at", "")), started_at=str(raw.get("started_at", "")),
compose_project=str(raw.get("compose_project", "")), compose_project=str(raw.get("compose_project", "")),
backend=str(raw.get("backend", "")),
) )
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.bot_bottle_root() / _STATE_SUBDIR / identity return _supervise.claude_bottle_root() / _STATE_SUBDIR / identity
def per_bottle_dockerfile_path(identity: str) -> Path: def per_bottle_dockerfile_path(identity: str) -> Path:
@@ -176,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
bot-bottle-claude:latest so per-bottle rebuilds don't collide in claude-bottle:latest so per-bottle rebuilds don't collide in
the docker image cache.""" the docker image cache."""
return f"bot-bottle-rebuilt-{identity}:latest" return f"claude-bottle-rebuilt-{identity}:latest"
def live_config_dir(identity: str) -> Path: def live_config_dir(identity: str) -> Path:
@@ -253,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/bot-bottle/current-config). (bind-mounted into the agent at /etc/claude-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
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it ~/.claude-bottle/queue/<slug>/ alongside the audit logs, so it
survives state-dir cleanup.""" survives state-dir cleanup."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
@@ -5,11 +5,11 @@ On approval of a capability-block proposal, the dashboard calls
apply_capability_change(slug, new_dockerfile) which: apply_capability_change(slug, new_dockerfile) which:
1. Snapshots the agent's transcript dir to 1. Snapshots the agent's transcript dir to
~/.bot-bottle/state/<slug>/transcript/ (best-effort). ~/.claude-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
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1 ~/.claude-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"bot-bottle-{slug}" return f"claude-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"bot-bottle-net-{slug}", f"claude-bottle-net-{slug}",
f"bot-bottle-egress-{slug}", f"claude-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 Claude Dockerfile (one dir above this module's """Path to the repo's 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."""
# bot_bottle/backend/docker/capability_apply.py -> repo root # claude_bottle/backend/docker/capability_apply.py -> repo root
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude" return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
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
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing ~/.claude-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 `bot-bottle-`. - Live compose projects whose name starts with `claude-bottle-`.
- `bot-bottle-*` containers that aren't part of any compose - `claude-bottle-*` containers that aren't part of any compose
project (legacy orphans). project (legacy orphans).
- `bot-bottle-*` networks that aren't tied to a compose - `claude-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 ~/.bot-bottle/state/<identity>/ with no - State dirs under ~/.claude-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 bot-bottle-prefixed containers, running or stopped.""" """All claude-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 bot-bottle-prefixed networks not currently attached """All claude-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.bot_bottle_root() / "state" state_root = _supervise.claude_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: `bot-bottle-<slug>`. - Compose project: `claude-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
(`bot-bottle-<service>-<slug>`) so dashboard/cleanup discovery (`claude-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
@@ -49,7 +49,7 @@ from ...egress import (
EGRESS_HOSTNAME, EGRESS_HOSTNAME,
EGRESS_ROUTES_IN_CONTAINER, EGRESS_ROUTES_IN_CONTAINER,
) )
from ...git_gate import GIT_GATE_HOSTNAME from ...git_gate import GIT_GATE_HOSTNAME, git_gate_aggregate_extra_hosts
from ...log import die, warn from ...log import die, warn
from ...pipelock import PIPELOCK_HOSTNAME from ...pipelock import PIPELOCK_HOSTNAME
from ...supervise import ( from ...supervise import (
@@ -59,7 +59,6 @@ from ...supervise import (
SUPERVISE_PORT, SUPERVISE_PORT,
) )
from ...util import expand_tilde from ...util import expand_tilde
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .egress import ( from .egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
@@ -76,6 +75,7 @@ from .pipelock import (
PIPELOCK_CA_KEY_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER,
PIPELOCK_PORT, PIPELOCK_PORT,
) )
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .sidecar_bundle import ( from .sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -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"bot-bottle-{plan.slug}" project = f"claude-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 `BOT_BOTTLE_SIDECAR_DAEMONS` - Daemon subset narrows via `CLAUDE_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 `bot-bottle-<service>-<slug>` long forms) so their `claude-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"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] env: list[str] = [f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
volumes: list[dict[str, Any]] = [] volumes: list[dict[str, Any]] = []
# --- pipelock ---------------------------------------------------- # --- pipelock ----------------------------------------------------
@@ -198,6 +198,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
env.append(token_env) env.append(token_env)
# --- git-gate ---------------------------------------------------- # --- git-gate ----------------------------------------------------
extra_hosts: list[str] = []
gp = plan.git_gate_plan gp = plan.git_gate_plan
if gp.upstreams: if gp.upstreams:
volumes += [ volumes += [
@@ -211,11 +212,8 @@ 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: extra_map = git_gate_aggregate_extra_hosts(gp.upstreams)
volumes.append(_bind( extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())]
u.known_hosts_file,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
))
# --- supervise --------------------------------------------------- # --- supervise ---------------------------------------------------
sp = plan.supervise_plan sp = plan.supervise_plan
@@ -258,6 +256,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"environment": env, "environment": env,
"volumes": volumes, "volumes": volumes,
} }
if extra_hosts:
service["extra_hosts"] = extra_hosts
return service return service
@@ -281,8 +281,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
] ]
for name, value in sorted(plan.agent_provision.guest_env.items()):
env.append(f"{name}={value}")
# Forwarded vars (OAuth token, manifest host-interpolations): # Forwarded vars (OAuth token, manifest host-interpolations):
# bare name → inherits from compose-up process env, value # bare name → inherits from compose-up process env, value
# never lands on argv or in the compose file. # never lands on argv or in the compose file.
@@ -353,7 +351,7 @@ COMPOSE_FILE_NAME = "docker-compose.yml"
COMPOSE_LOG_NAME = "compose.log" COMPOSE_LOG_NAME = "compose.log"
COMPOSE_PROJECT_PREFIX = "bot-bottle-" COMPOSE_PROJECT_PREFIX = "claude-bottle-"
def compose_project_name(slug: str) -> str: def compose_project_name(slug: str) -> str:
@@ -373,20 +371,15 @@ def slug_from_compose_project(project: str) -> str:
return project[len(COMPOSE_PROJECT_PREFIX):] return project[len(COMPOSE_PROJECT_PREFIX):]
def list_compose_projects( def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
*, include_stopped: bool = True, warn_on_error: bool = True, """All compose project names starting with `claude-bottle-`.
) -> 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". `warn_on_error` projects discoverable", not "no projects exist"."""
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")
@@ -399,14 +392,12 @@ def list_compose_projects(
# 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] = []
for p in projects: for p in projects:
@@ -418,19 +409,14 @@ def list_compose_projects(
return sorted(set(names)) return sorted(set(names))
def list_active_slugs( def list_active_slugs(*, include_stopped: bool = False) -> list[str]:
*, 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( for p in list_compose_projects(include_stopped=include_stopped)
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("BOT_BOTTLE_EGRESS_PORT", "9099")) EGRESS_PORT = int(os.environ.get("CLAUDE_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 = bot-bottle\n" "O = claude-bottle\n"
"CN = bot-bottle egress CA\n" "CN = claude-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
# ~/.bot-bottle which inherits ~'s 0o700) is what actually # ~/.claude-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, warn_on_error=False) slugs = list_active_slugs(include_stopped=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_agent` runs claude via 9. Yield a DockerBottle handle. `exec_claude` 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):
@@ -43,7 +43,7 @@ from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...egress import egress_resolve_token_values from ...egress import egress_resolve_token_values
from ...log import info, warn from ...log import info
from . import network as network_mod from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
@@ -87,11 +87,10 @@ def launch(
def teardown() -> None: def teardown() -> None:
try: try:
stack.close() stack.close()
except BaseException as exc: except BaseException:
warn( # Teardown must not raise; swallow so the caller's
f"teardown failed for container {plan.container_name}" # __exit__ path can still propagate the original error.
f" (compose-down): {exc!r}" pass
)
try: try:
# Step 1: agent image build. Sidecar images get built lazily by # Step 1: agent image build. Sidecar images get built lazily by
@@ -102,7 +101,7 @@ def launch(
) )
if plan.derived_image: if plan.derived_image:
docker_mod.build_image_with_cwd( docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.workspace_plan plan.derived_image, plan.image, plan.spec.user_cwd
) )
# Networks: compose-managed. The names are derived # Networks: compose-managed. The names are derived
@@ -177,10 +176,11 @@ def launch(
# Step 7: compose up. Token values + the OAuth placeholder # Step 7: compose up. Token values + the OAuth placeholder
# flow through subprocess env; the compose file holds only # flow through subprocess env; the compose file holds only
# bare names for the secret-carrying entries. # bare names for the secret-carrying entries.
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} token_values: dict[str, str] = {}
token_values = egress_resolve_token_values( if plan.egress_plan.routes:
plan.egress_plan.token_env_map, effective_env, token_values = egress_resolve_token_values(
) plan.egress_plan.token_env_map, dict(os.environ),
)
compose_env: dict[str, str] = { compose_env: dict[str, str] = {
**os.environ, **os.environ,
**plan.forwarded_env, **plan.forwarded_env,
@@ -204,15 +204,9 @@ 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_agent continues to use `docker exec -it` # Step 9: yield. exec_claude 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( yield DockerBottle(plan.container_name, teardown, prompt_path)
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: bot-bottle-net-<slug> (internal), Naming: claude-bottle-net-<slug> (internal),
bot-bottle-egress-<slug> (egress). Numeric suffix on conflict claude-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"bot-bottle-net-{slug}" return f"claude-bottle-net-{slug}"
def network_egress_name_for_slug(slug: str) -> str: def network_egress_name_for_slug(slug: str) -> str:
return f"bot-bottle-egress-{slug}" return f"claude-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(
"BOT_BOTTLE_PIPELOCK_IMAGE", "CLAUDE_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("BOT_BOTTLE_PIPELOCK_PORT", "8888") PIPELOCK_PORT = os.environ.get("CLAUDE_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
@@ -12,17 +12,14 @@ from __future__ import annotations
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path from pathlib import Path
from ...agent_provider import agent_provision_plan, 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
from ...log import die from ...log import die
from ...pipelock import PipelockProxy from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec from .. import BottleSpec
from . import util as docker_mod from . import util as docker_mod
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -61,10 +58,6 @@ 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)
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
# 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
@@ -81,8 +74,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"bot-bottle-{slug}", compose_project=f"claude-bottle-{slug}",
backend="docker",
)) ))
# 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
@@ -97,32 +89,26 @@ 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 = provider_runtime.image image_default = "claude-bottle:latest"
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default) image = os.environ.get("CLAUDE_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(
"BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}" "CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}"
) )
runtime_image = derived_image runtime_image = derived_image
default_container = f"bot-bottle-{slug}" default_container = f"claude-bottle-{slug}"
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "") pinned_container = os.environ.get("CLAUDE_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 BOT_BOTTLE_CONTAINER). " f"(pinned via CLAUDE_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:
@@ -152,7 +138,7 @@ def resolve_plan(
) )
# PRD 0018 chunk 2: prepare-time scratch files live under # PRD 0018 chunk 2: prepare-time scratch files live under
# ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose # ~/.claude-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).
@@ -163,45 +149,17 @@ def resolve_plan(
prompt_file.write_text("") prompt_file.write_text("")
prompt_file.chmod(0o600) prompt_file.chmod(0o600)
pipelock_dir = pipelock_state_dir(slug)
pipelock_dir.mkdir(parents=True, exist_ok=True)
proxy_plan = proxy.prepare(bottle, slug, pipelock_dir)
git_gate_dir = git_gate_state_dir(slug) git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True) git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir) git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir)
resolved = resolve_env(manifest, spec.agent_name)
# Everything that should reach the bottle by-name (so its value
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
_write_env_file(resolved, env_file)
prompt_file.write_text(agent.prompt)
use_runsc = docker_mod.runsc_available()
agent_provision = agent_provision_plan(
template=provider.template,
dockerfile=dockerfile_path,
state_dir=agent_dir,
guest_home=guest_home,
forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
)
guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items():
guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=guest_env)
pipelock_dir = pipelock_state_dir(slug)
pipelock_dir.mkdir(parents=True, exist_ok=True)
proxy_plan = proxy.prepare(
bottle, slug, pipelock_dir, agent_provision.egress_routes,
)
egress_dir = egress_state_dir(slug) egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True) egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = egress.prepare( egress_plan = egress.prepare(bottle, slug, egress_dir)
bottle, slug, egress_dir, agent_provision.egress_routes,
)
supervise_plan = None supervise_plan = None
if bottle.supervise: if bottle.supervise:
@@ -213,22 +171,41 @@ 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.)
supervise_dockerfile_path = ( dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
Path(dockerfile_path) dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
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(
slug, supervise_dir, slug, supervise_dir,
dockerfile_content=dockerfile_content, dockerfile_content=dockerfile_content,
) )
resolved = resolve_env(manifest, spec.agent_name)
# Everything that should reach the bottle by-name (so its value
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
# When the bottle declares an egress route with the
# `claude_code_oauth` role marker, claude-code's outbound
# Authorization gets stripped + re-injected by egress. The
# agent's environ still needs *something* claude-code recognises
# as a credential or it refuses to start; ship a non-secret
# placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress is in
# front. Manifest validation enforces singleton on this role.
has_anthropic_auth = any(
"claude_code_oauth" in r.roles
for r in egress_plan.routes
)
if has_anthropic_auth:
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
# Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
_write_env_file(resolved, env_file)
prompt_file.write_text(agent.prompt)
use_runsc = docker_mod.runsc_available()
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
@@ -248,8 +225,6 @@ 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_provision=agent_provision,
workspace_plan=workspace_plan,
) )
@@ -268,10 +243,3 @@ 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)
@@ -31,18 +31,54 @@ stage dir; nothing in the agent ever sees it."""
from __future__ import annotations from __future__ import annotations
import hashlib
import ssl
import subprocess import subprocess
from pathlib import Path
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert from ....log import info
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
# Debian-family path for sources that `update-ca-certificates` reads.
# Bundle path is what the command rebuilds and what every standard
# TLS consumer in the image reads.
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt"
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
def _select_ca_cert(plan: DockerBottlePlan) -> tuple[Path, str]:
"""Pick the CA cert (and a short label for the log line) that
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
wins when the bottle declares any routes (it sits in front of
pipelock); else pipelock."""
if plan.egress_plan.routes:
cert = plan.egress_plan.mitmproxy_ca_cert_only_host_path
if cert == Path() or not cert.is_file():
from ....log import die
die(
f"egress CA cert missing at {cert or '(empty)'}; "
f"launch must have called egress_tls_init and "
f"re-bound the plan before provision"
)
return cert, "egress"
cert = plan.proxy_plan.ca_cert_host_path
if not cert or not cert.is_file():
from ....log import die
die(
f"pipelock CA cert missing at {cert or '(empty)'}; "
f"launch must have called pipelock_tls_init and re-bound "
f"the plan before provision"
)
return cert, "pipelock"
def provision_ca(plan: DockerBottlePlan, target: str) -> None: def provision_ca(plan: DockerBottlePlan, target: str) -> None:
"""Copy the agent-facing CA cert into the agent, rebuild the """Copy the agent-facing CA cert into the agent, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up.""" `BottleBackend.provision` after the agent container is up."""
container = target container = target
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) cert_host_path, label = _select_ca_cert(plan)
subprocess.run( subprocess.run(
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"], ["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
@@ -60,4 +96,8 @@ def provision_ca(plan: DockerBottlePlan, target: str) -> None:
check=True, check=True,
) )
log_ca_fingerprint(cert_host_path, label) # Stdlib SHA-256 of the cert's DER bytes — the standard
# fingerprint form. Never the private key.
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
fingerprint = hashlib.sha256(der).hexdigest()
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
@@ -0,0 +1,80 @@
"""Git provisioning inside a running Docker bottle.
Two concerns, both about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that .git
into /home/node/workspace/.git so the agent operates on the
user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation
against a declared upstream (push, fetch, clone, pull,
ls-remote) transparently hits the per-agent git-gate. The
gate mirrors the upstream in both directions, so URL
rewriting is symmetric.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
from ....log import info
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan
def provision_git(plan: DockerBottlePlan, target: str) -> None:
"""Set up git inside the bottle. Runs both subcases; each no-ops
when its condition isn't met."""
_provision_cwd_git(plan, target)
_provision_git_gate_config(plan, target)
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op
otherwise."""
if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()):
return
container = target
info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git")
subprocess.run(
["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
[
"docker", "exec", "-u", "0", container,
"chown", "-R", "node:node", "/home/node/workspace/.git",
],
stdout=subprocess.DEVNULL,
check=True,
)
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
"""Write ~/.gitconfig in the bottle with the git-gate
insteadOf rules. No-op when the bottle has no `git` entries."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not bottle.git:
return
container = target
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig"
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
config_file = plan.stage_dir / "agent_gitconfig"
config_file.write_text(content)
config_file.chmod(0o600)
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
subprocess.run(
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
@@ -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("BOT_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt" in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
subprocess.run( subprocess.run(
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"], ["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
@@ -28,9 +28,9 @@ def provision_skills(plan: DockerBottlePlan, target: str) -> None:
return return
container = target container = target
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
skills_dir = os.environ.get( skills_dir = os.environ.get(
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills" "CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
) )
subprocess.run( subprocess.run(
@@ -5,7 +5,7 @@ The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs pipelock + egress + git-gate + supervise as one container runs pipelock + egress + git-gate + supervise as one container
per bottle under a small Python init supervisor. As of chunk 5 per bottle under a small Python init supervisor. As of chunk 5
the bundle is the only shape the legacy four-sidecar topology the bundle is the only shape the legacy four-sidecar topology
and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" and its `CLAUDE_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 `BOT_BOTTLE_PIPELOCK_IMAGE` shape. # the existing `CLAUDE_BOTTLE_PIPELOCK_IMAGE` shape.
SIDECAR_BUNDLE_IMAGE = os.environ.get( SIDECAR_BUNDLE_IMAGE = os.environ.get(
"BOT_BOTTLE_SIDECAR_IMAGE", "CLAUDE_BOTTLE_SIDECAR_IMAGE",
"bot-bottle-sidecars:latest", "claude-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:
"""`bot-bottle-sidecars-<slug>`. Same prefix scheme as the """`claude-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"bot-bottle-sidecars-{slug}" return f"claude-bottle-sidecars-{slug}"
@@ -7,11 +7,9 @@ from __future__ import annotations
import re import re
import shutil import shutil
import subprocess import subprocess
import tempfile
from typing import Iterable, Iterator from typing import Iterable, Iterator
from ...log import die, info from ...log import die, info
from ...workspace import WorkspacePlan
# Cap on the suffix the container-name conflict logic will try before # Cap on the suffix the container-name conflict logic will try before
@@ -118,39 +116,35 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True) subprocess.run(args, check=True)
def build_image_with_cwd( _TRUST_DIALOG_NODE_SCRIPT = (
derived: str, 'const fs=require("fs"),p=process.env.HOME+"/.claude.json",'
base: str, 'c=JSON.parse(fs.readFileSync(p,"utf8"));'
workspace: WorkspacePlan, 'c.projects=c.projects||{};'
) -> None: 'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};'
"""Build a thin derived image that copies the workspace into 'fs.writeFileSync(p,JSON.stringify(c,null,2));'
the plan's guest path and sets the plan's workdir.""" )
def build_image_with_cwd(derived: str, base: str, cwd: str) -> None:
"""Build a thin derived image that copies <cwd> into
/home/node/workspace and adds a trust-dialog entry for it."""
import os import os
cwd = str(workspace.host_path)
if not os.path.isdir(cwd): if not os.path.isdir(cwd):
die(f"cwd not found at {cwd}") die(f"cwd not found at {cwd}")
info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}") info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace")
with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp: dockerfile = (
context_dir = os.path.join(tmp, "context") f"FROM {base}\n"
staged_workspace = os.path.join(context_dir, "workspace") f"COPY --chown=node:node . /home/node/workspace\n"
shutil.copytree( f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n"
cwd, f"WORKDIR /home/node/workspace\n"
staged_workspace, )
symlinks=True, subprocess.run(
ignore=shutil.ignore_patterns(".git"), ["docker", "build", "-t", derived, "-f", "-", cwd],
) input=dockerfile,
dockerfile = ( text=True,
f"FROM {base}\n" check=True,
f"COPY --chown=node:node workspace/. {workspace.guest_path}\n" )
f"WORKDIR {workspace.workdir}\n"
)
subprocess.run(
["docker", "build", "-t", derived, "-f", "-", context_dir],
input=dockerfile,
text=True,
check=True,
)
def image_id(ref: str) -> str: def image_id(ref: str) -> str:
@@ -26,16 +26,3 @@ 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], *, hidden_env_names: frozenset[str],
) -> list[str]:
"""Env names worth showing in launch summaries.
Provider-injected placeholder env vars are implementation details:
they are non-secret dummy values that satisfy provider CLIs while
egress injects the real Authorization header. The plan's
`hidden_env_names` carries exactly which names to suppress.
"""
return sorted({name for name in env_names if name and name not in hidden_env_names})
@@ -1,6 +1,6 @@
"""smolmachines bottle backend (PRD 0023). """smolmachines bottle backend (PRD 0023).
Selectable via `BOT_BOTTLE_BACKEND=smolmachines`. Runs each Selectable via `CLAUDE_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
@@ -19,17 +19,15 @@ from .bottle_plan import SmolmachinesBottlePlan
from .provision import ca as _ca from .provision import ca as _ca
from .provision import git as _git from .provision import git as _git
from .provision import prompt as _prompt from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth
from .provision import skills as _skills from .provision import skills as _skills
from .provision import supervise as _supervise from .provision import supervise as _supervise
from .provision import workspace as _workspace
class SmolmachinesBottleBackend( class SmolmachinesBottleBackend(
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"] BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
): ):
"""smolmachines backend. Selected by """smolmachines backend. Selected by
`BOT_BOTTLE_BACKEND=smolmachines`.""" `CLAUDE_BOTTLE_BACKEND=smolmachines`."""
name = "smolmachines" name = "smolmachines"
@@ -63,21 +61,11 @@ class SmolmachinesBottleBackend(
) -> str | None: ) -> str | None:
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, target)
def provision_provider_auth(
self, plan: SmolmachinesBottlePlan, target: str
) -> None:
_provider_auth.provision_provider_auth(plan, target)
def provision_skills( def provision_skills(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, target: str
) -> None: ) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, target)
def provision_workspace(
self, plan: SmolmachinesBottlePlan, target: str
) -> None:
_workspace.provision_workspace(plan, target)
def provision_git( def provision_git(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, target: str
) -> None: ) -> None:
@@ -1,15 +1,15 @@
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d). """SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine Routes `exec_claude` / `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 agent CLIs may refuse VM, but the agent image's USER is `node` and claude-code refuses
to run as root in bypass modes. Both to run as root with `--dangerously-skip-permissions`. Both
`exec_agent` and `exec` switch to the requested user (default `exec_claude` 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
@@ -18,24 +18,14 @@ minimal Debian VM with no PAM session config."""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
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 smolvm as _smolvm from . import smolvm as _smolvm
# Absolute path to the pty_resize wrapper. Invoke as # Per-user env the agent image's USER (node) expects. claude
# `python <path>` rather than `python -m <dotted-path>` so the # reads ~/.claude.json + writes session state under ~/.claude/;
# wrapper runs regardless of cwd / sys.path — it has no
# bot_bottle.* imports, so it's self-contained.
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
# Per-user env the agent image's USER (node) expects. Some providers
# write session state under the user's home directory;
# bare `runuser -u` inherits root's HOME=/root, which claude # 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.
@@ -45,11 +35,19 @@ _HOME_FOR = {
} }
def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]: def _env_flags_for(user: str) -> list[str]:
home = _HOME_FOR.get(user, f"/home/{user}") home = _HOME_FOR.get(user, f"/home/{user}")
out = [f"HOME={home}", f"USER={user}"] return ["-e", f"HOME={home}", "-e", f"USER={user}"]
def _guest_env_flags(env: Mapping[str, str]) -> list[str]:
"""Render `{K: V}` into a flat `-e K=V` argv slice for
`smolvm machine exec`. `smolvm machine create -e` set env
on PID 1 but it doesn't propagate to fresh exec process
trees, so we have to re-pass them every call."""
out: list[str] = []
for k, v in env.items(): for k, v in env.items():
out.append(f"{k}={v}") out += ["-e", f"{k}={v}"]
return out return out
@@ -65,8 +63,6 @@ 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
@@ -78,45 +74,11 @@ 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 agent_argv( def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
self, argv: list[str], *, tty: bool = True, """Run `claude` interactively inside the VM as the `node`
) -> list[str]:
flags = ["smolvm", "machine", "exec", "--name", self.name]
if tty:
flags += ["-i", "-t"]
agent_tail = ["env", *_env_assignments_for("node", self._guest_env),
self.agent_command]
provider_prompt_args = prompt_args(
self._agent_prompt_mode, self._prompt_path, argv=argv,
)
if self._agent_prompt_mode == "read_prompt_file":
agent_tail += argv
agent_tail += provider_prompt_args
else:
agent_tail += provider_prompt_args
agent_tail += argv
flags += ["--", "runuser", "-u", "node", "--", *agent_tail]
if not tty:
# No PTY allocated — no SIGWINCH to forward, no resize
# bridge needed. Skip the wrapper so non-interactive
# exec paths (e.g., provisioning shell-outs that
# happen to go through this method) stay light.
return flags
return [
sys.executable, _PTY_RESIZE_SCRIPT,
self.name, "--", *flags,
]
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
"""Run the selected agent interactively inside the VM as the `node`
user. Inherits the operator's terminal (stdin / stdout / user. Inherits the operator's terminal (stdin / stdout /
stderr) so the session feels native. Blocks until the agent stderr) so the session feels native. Blocks until claude
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
@@ -127,9 +89,18 @@ class SmolmachinesBottle(Bottle):
UID switches via `runuser -u node --` (not `-l`) so we UID switches via `runuser -u node --` (not `-l`) so we
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( flags = ["smolvm", "machine", "exec", "--name", self.name]
self.agent_argv(argv, tty=tty), check=False, if tty:
).returncode flags += ["-i", "-t"]
flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env)
claude_argv = ["claude"]
if self._prompt_path:
claude_argv += ["--append-system-prompt-file", self._prompt_path]
claude_argv += argv
flags += ["--", "runuser", "-u", "node", "--", *claude_argv]
result = subprocess.run(flags, check=False)
return result.returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str, *, user: str = "node") -> ExecResult:
"""Run a POSIX shell script as `user` (default `node`) and """Run a POSIX shell script as `user` (default `node`) and
@@ -139,16 +110,16 @@ class SmolmachinesBottle(Bottle):
on both backends. Pass `user="root"` for tests that need on both backends. Pass `user="root"` for tests that need
root. root.
`runuser -u <user> -- env ... /bin/sh -c <script>` switches UID `runuser -u <user> -- /bin/sh -c <script>` switches UID
without invoking a login shell, then sets HOME / USER and the without invoking a login shell; HOME / USER are set via
bottle env in the child process.""" `smolvm -e` (see `_env_flags_for`)."""
argv = [ argv = (
"--", "runuser", "-u", user, "--", _env_flags_for(user)
"env", *_env_assignments_for(user, self._guest_env), + _guest_env_flags(self._guest_env)
"/bin/sh", "-c", script, + ["--", "runuser", "-u", user, "--", "/bin/sh", "-c", script]
] )
# Call smolvm directly because this path needs the host-side # _smolvm.machine_exec expects argv (the bit after `--`);
# subprocess capture shape used by the Docker backend. # the -e flags go before, so call smolvm directly.
r = subprocess.run( r = subprocess.run(
["smolvm", "machine", "exec", "--name", self.name] + argv, ["smolvm", "machine", "exec", "--name", self.name] + argv,
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
@@ -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
`bot-bottle-` (running or stopped). Stopped + `claude-bottle-` (running or stopped). Stopped +
deleted via `smolvm machine stop` + `machine delete -f`. deleted via `smolvm machine stop` + `machine delete -f`.
- bundles: docker containers `bot-bottle-sidecars-<slug>` - bundles: docker containers `claude-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 `bot-bottle-bundle-<slug>` - networks: docker networks `claude-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 `~/.bot-bottle/state/` Smolmachines state dirs live under the same `~/.claude-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
@@ -8,20 +8,24 @@ in chunk 4."""
from __future__ import annotations from __future__ import annotations
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 ...git_gate import GitGatePlan
from ...log import info
from ...pipelock import PipelockProxyPlan from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan
from .. import BottlePlan from .. import BottlePlan
from ..print_util import print_multi
@dataclass(frozen=True) @dataclass(frozen=True)
class SmolmachinesBottlePlan(BottlePlan): class SmolmachinesBottlePlan(BottlePlan):
"""Resolved fields the launch step needs to bring up the bottle. """Resolved fields the launch step needs to bring up the bottle.
Inherits `spec`, `stage_dir`, `git_gate_plan`, `egress_plan`, Inherits `spec` and `stage_dir` from BottlePlan."""
`supervise_plan`, and `agent_provision` from BottlePlan."""
slug: str slug: str
# Per-bottle docker subnet for the sidecar bundle container. # Per-bottle docker subnet for the sidecar bundle container.
@@ -38,19 +42,13 @@ 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 bot-bottle-claude:latest lives in the operator's local # since claude-bottle: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
# transport). # transport).
machine_name: str machine_name: str
# Agent image ref (docker tag). `launch` runs the agent_from_path: Path
# build → save → registry push → smolvm pack pipeline against
# this and feeds the resulting `.smolmachine` artifact to
# `machine_create --from`. The pipeline runs at launch time
# (not prepare time) so the docker build output doesn't garble
# the dashboard's preflight modal.
agent_image_ref: str
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since # In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist. # the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags. # Passed to `smolvm machine create` as `-e K=V` flags.
@@ -63,7 +61,7 @@ class SmolmachinesBottlePlan(BottlePlan):
# empty when the agent has no prompt — claude-code reads it # empty when the agent has no prompt — claude-code reads it
# via --append-system-prompt-file only when non-empty. # via --append-system-prompt-file only when non-empty.
prompt_file: Path prompt_file: Path
# Inner Plans for the sidecar bundle daemons. The same shape the # Inner Plans for the four bundle daemons. The same shape the
# docker backend uses — same `.prepare()` calls produced # docker backend uses — same `.prepare()` calls produced
# them — but our launch step doesn't populate the # them — but our launch step doesn't populate the
# docker-specific network fields (internal_network, # docker-specific network fields (internal_network,
@@ -72,6 +70,11 @@ class SmolmachinesBottlePlan(BottlePlan):
# per-bottle bridge with a pinned IP. The unused fields stay # per-bottle bridge with a pinned IP. The unused fields stay
# at their dataclass defaults. # at their dataclass defaults.
proxy_plan: PipelockProxyPlan proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
# None when bottle.supervise is False, matching the docker
# backend's convention.
supervise_plan: SupervisePlan | None
# Agent-side endpoints. On Docker Desktop the docker bridge # Agent-side endpoints. On Docker Desktop the docker bridge
# IPs aren't reachable from the smolvm guest (TSI uses macOS # IPs aren't reachable from the smolvm guest (TSI uses macOS
# networking; docker container IPs live in the daemon's VM), # networking; docker container IPs live in the daemon's VM),
@@ -84,18 +87,31 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = "" agent_git_gate_host: str = ""
agent_supervise_url: str = "" agent_supervise_url: str = ""
@property def print(self, *, remote_control: bool) -> None:
def agent_command(self) -> str: """Compact y/N preflight. Same shape as the Docker
return self.agent_provision.command backend's so operators see one format across backends."""
del remote_control # not surfaced in the compact summary
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
@property env_names = sorted(bottle.env.keys())
def agent_prompt_mode(self) -> PromptMode: upstreams = [
return self.agent_provision.prompt_mode f"{g.Name}{g.Upstream}" for g in bottle.git
]
# Use the resolved egress_plan (lowercase `host` on the
# plan-level EgressRoute) rather than `bottle.egress.routes`,
# which is the manifest's capitalized-attr form.
routes = [r.host for r in self.egress_plan.routes]
@property print(file=sys.stderr)
def agent_provider_template(self) -> str: info(f"agent : {spec.agent_name}")
return self.agent_provision.template print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
@property info(f"bottle : {agent.bottle}")
def agent_dockerfile_path(self) -> str: if upstreams:
return self.agent_provision.dockerfile print_multi(" git gate ", upstreams)
if routes:
print_multi(" egress ", routes)
print(file=sys.stderr)
@@ -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 `bot-bottle-`. with `claude-bottle-`.
- bundle docker containers (`bot-bottle-sidecars-<slug>`). - bundle docker containers (`claude-bottle-sidecars-<slug>`).
- bundle docker networks (`bot-bottle-bundle-<slug>`). - bundle docker networks (`claude-bottle-bundle-<slug>`).
State dirs live under `~/.bot-bottle/state/<identity>/` State dirs live under `~/.claude-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 = "bot-bottle-" _VM_PREFIX = "claude-bottle-"
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `bot-bottle-sidecars-` _BUNDLE_PREFIX = _bundle.bundle_container_name("") # `claude-bottle-sidecars-`
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `bot-bottle-bundle-` _NETWORK_PREFIX = _bundle.bundle_network_name("") # `claude-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_bot_bottle_machines() machines = _list_claude_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_bot_bottle_machines() -> list[str]: def _list_claude_bottle_machines() -> list[str]:
"""All smolvm machines named `bot-bottle-*`, regardless of """All smolvm machines named `claude-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_bot_bottle_machines() -> list[str]:
def _list_bundle_containers() -> list[str]: def _list_bundle_containers() -> list[str]:
"""All docker containers named `bot-bottle-sidecars-*`, """All docker containers named `claude-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 `bot-bottle-bundle-*`. Empty """All docker networks named `claude-bottle-bundle-*`. Empty
when docker isn't installed.""" when docker isn't installed."""
from .. import has_backend from .. import has_backend
if not has_backend("docker"): if not has_backend("docker"):
@@ -27,10 +27,10 @@ from ..docker.bottle_state import read_metadata
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
# Smolvm VM names produced by prepare are `bot-bottle-<slug>`, # Smolvm VM names produced by prepare are `claude-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 = "bot-bottle-" _VM_NAME_PREFIX = "claude-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 `BOT_BOTTLE_SIDECAR_DAEMONS` env var. bundle container's `CLAUDE_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 == "BOT_BOTTLE_SIDECAR_DAEMONS": if key == "CLAUDE_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
)) ))
@@ -0,0 +1,391 @@
"""End-to-end launch flow for the smolmachines backend
(PRD 0023 chunks 2d + 4b).
Brings up the per-bottle docker bridge + sidecar bundle (with
real daemons + their config files), creates + starts the smolvm
guest pointed at the bundle's pinned IP via TSI's
`--allow-cidr <bundle-ip>/32` allowlist, yields a
`SmolmachinesBottle` handle, tears everything down on context
exit.
The bundle's daemons consume the inner Plans the docker backend
already produces: pipelock reads its yaml + CA from the
PipelockProxyPlan; egress reads routes + CAs from the EgressPlan
+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle
local), since the agent dials pipelock first (not egress) on the
smolmachines path. Git-gate + supervise plumb through the same
plans the docker backend uses, minus the docker-network fields
that don't apply here."""
from __future__ import annotations
import dataclasses
import os
import time
from contextlib import ExitStack, contextmanager
from typing import Callable, Generator
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
from ...pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
)
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde
from ..docker.egress import (
EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
EGRESS_PORT as _EGRESS_PORT,
egress_tls_init,
)
from ..docker.git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
GIT_GATE_PORT as _GIT_GATE_PORT,
)
from ..docker.pipelock import (
BUNDLE_LOCAL_PIPELOCK_URL,
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
pipelock_tls_init,
)
from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle
from .bottle_plan import SmolmachinesBottlePlan
# Container-internal listening ports for each bundle daemon. The
# bundle publishes each one on a random host loopback port (see
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
# them up post-start. Pipelock's port is an env-overridable string
# in docker.pipelock; coerce to int here.
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
_SUPERVISE_PORT = SUPERVISE_PORT
@contextmanager
def launch(
plan: SmolmachinesBottlePlan,
*,
provision: Callable[[SmolmachinesBottlePlan, str], str | None],
) -> Generator[SmolmachinesBottle, None, None]:
"""Build + run the bottle and yield a handle; tear everything
down on exit. Errors during bringup unwind any partial state
via the ExitStack."""
stack = ExitStack()
try:
# 1. Reserve a loopback alias for this bottle. macOS only
# routes 127.0.0.1 by default; the per-bottle alias is
# what bundles the docker port-publishes and TSI allowlist
# against, so this bottle can't reach other bottles' (or
# other host services') ports on the loopback. Lazy
# sudo-driven on first use per boot. No-op on Linux.
_loopback.ensure_pool()
loopback_ip = _loopback.allocate(plan.slug)
# 2. Per-bottle docker bridge.
network = _bundle.bundle_network_name(plan.slug)
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
stack.callback(_bundle.remove_bundle_network, network)
# 2. Mint per-bottle CAs and update the inner Plans with
# their launch-time paths. pipelock always runs in the
# bundle; egress's CA is only minted when the bottle
# declares routes (otherwise egress runs idle without
# MITM and the CA files would be unused).
ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent)
proxy_plan = dataclasses.replace(
plan.proxy_plan,
ca_cert_host_path=ca_cert_host,
ca_key_host_path=ca_key_host,
)
egress_plan = plan.egress_plan
if egress_plan.routes:
egress_ca_host, egress_ca_cert_only = egress_tls_init(
plan.egress_plan.routes_path.parent,
)
egress_plan = dataclasses.replace(
egress_plan,
mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
pipelock_ca_host_path=ca_cert_host,
# On smolmachines, egress's upstream is pipelock
# on the bundle's localhost — they're in the same
# container's network namespace.
pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL,
)
plan = dataclasses.replace(
plan, proxy_plan=proxy_plan, egress_plan=egress_plan,
)
# 3. Build the BundleLaunchSpec from the (now-resolved)
# inner Plans: daemon subset, env, bind-mounts, and the
# loopback alias to bind published ports against. The
# spec's ports_to_publish list expands depending on which
# daemons the agent needs to reach from the smolvm guest.
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
token_env = _resolve_token_env(plan, os.environ)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
stack.callback(_bundle.stop_bundle, plan.slug)
# 4. Discover the host-side ports docker assigned for the
# bundle's published container ports, and bind the
# agent's URLs to `<loopback_ip>:<host port>`. Docker
# container IPs (192.168.x.x in the daemon's bridge)
# aren't reachable from the smolvm guest on macOS — TSI
# uses macOS networking, and macOS sees the daemon's
# bridge via the published-port loopback forward only.
#
# Proxy hop order matches the docker backend: when the
# bottle declares egress routes, the agent's first hop is
# egress (for token injection), then pipelock. Without
# routes, the agent dials pipelock directly. Whichever
# one is "agent-facing" is the daemon whose port we
# publish on host loopback; the other stays bundle-
# internal as the upstream proxy.
if plan.egress_plan.routes:
agent_facing_port = _EGRESS_PORT
else:
agent_facing_port = _PIPELOCK_PORT
agent_facing_host_port = _bundle.bundle_host_port(
plan.slug, agent_facing_port, host_ip=loopback_ip,
)
agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
agent_git_gate_host = ""
if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port(
plan.slug, _GIT_GATE_PORT, host_ip=loopback_ip,
)
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_supervise_url = ""
if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
)
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
# Stamp the URLs onto the plan + guest_env. provision_git
# and provision_supervise read the plan fields; the agent
# reads guest_env on every exec_claude.
#
# NO_PROXY has to include the per-bottle loopback alias —
# otherwise claude's HTTPS_PROXY catches direct calls to
# the supervise URL (`http://<alias>:<port>/`) and proxies
# them through egress, which has no route for the alias
# and rejects with "Failed to connect". The git-gate URL
# uses git://, not affected by HTTP_PROXY, so the alias
# only has to be in NO_PROXY for the MCP / supervise
# path. Append rather than overwrite so prepare.py's
# `localhost,127.0.0.1` baseline stays in place.
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
guest_env = {
**plan.guest_env,
"HTTPS_PROXY": agent_proxy_url,
"HTTP_PROXY": agent_proxy_url,
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
}
if agent_git_gate_host:
guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}"
if agent_supervise_url:
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
plan = dataclasses.replace(
plan,
guest_env=guest_env,
agent_proxy_url=agent_proxy_url,
agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url,
)
# 5. smolvm VM. --from carries the pre-packed .smolmachine
# artifact (built by prepare); --allow-cidr + -e carry the
# per-bottle TSI allowlist + env. The allowlist is the
# per-bottle loopback alias — narrowing it to one /32 keeps
# the agent from reaching other host loopback services or
# other bottles' published ports. Smolfile isn't usable
# here — smolvm 0.8.0 makes `--from` and `--smolfile`
# mutually exclusive.
_smolvm.machine_create(
plan.machine_name,
from_path=plan.agent_from_path,
allow_cidrs=[f"{loopback_ip}/32"],
env=plan.guest_env,
)
stack.callback(_smolvm.machine_delete, plan.machine_name)
# Workaround smolvm 0.8.0: `--allow-cidr` is silently
# dropped when combined with `--from`. Patch the persisted
# state DB to set the allowlist before start so the booted
# VM's TSI actually enforces. See loopback_alias's module
# docstring for the investigation that led here.
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
_smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name)
# 6. Repair filesystem ownership + perms that smolvm's
# pack process remapped to the host invoker's uid (501
# on macOS) rather than preserving the image's expected
# ownership.
#
# - /home/node → node:node so the node user can write
# its own dotfiles (claude appendFileSync on
# ~/.claude.json otherwise bails with ENOENT/EPERM
# and the TUI hangs without surfacing the error).
# - /tmp + /var/tmp → root:root mode 1777 so non-root
# processes can create their per-uid scratch dirs
# (claude-code creates /tmp/claude-<uid>/ as soon as
# it spawns a Bash tool call).
#
# All folded into one sh -c so we only pay one
# machine_exec round trip — back-to-back exec calls
# right after machine_start hit a SIGKILL race in
# libkrun's exec channel (see provision_ca for the
# other half of this same workaround).
_smolvm.machine_exec(plan.machine_name, [
"sh", "-c",
"chown -R node:node /home/node && "
"chown root:root /tmp /var/tmp && "
"chmod 1777 /tmp /var/tmp",
])
# Wait briefly for the VM to settle. Back-to-back smolvm
# machine_exec calls immediately after machine_start
# occasionally SIGKILL the in-VM child at ~100ms (looks
# like a VM warm-up race in libkrun's exec channel).
# 1.5s is empirically enough to dodge it; provisioning
# already takes seconds so the wait is amortized.
time.sleep(1.5)
# 7. Provision (CA / prompt / skills / git / supervise).
prompt_path = provision(plan, plan.machine_name)
yield SmolmachinesBottle(
plan.machine_name,
prompt_path=prompt_path,
guest_env=plan.guest_env,
)
finally:
stack.close()
def _bundle_launch_spec(
plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
) -> _bundle.BundleLaunchSpec:
"""Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV:
- egress + pipelock are always present (pipelock is the
agent's first hop; egress is its upstream).
- git-gate is conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan.
Env + volumes are the union of the four daemons' needs, with
daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh — see PRD 0024's bundle
bind-address PR)."""
daemons: list[str] = ["egress", "pipelock"]
env: list[str] = []
volumes: list[tuple[str, str, bool]] = []
# In this Docker-Desktop-compatible topology, whichever daemon
# is "agent-facing" gets its port published on the host
# loopback (see `_ensure_smolmachine`'s discovery loop) and the
# other stays bundle-internal. The bundle is NOT reachable by
# bridge IP from the smolvm guest, so the
# PRD-0023-chunk-3 EGRESS_LISTEN_HOST=127.0.0.1 mitigation
# isn't needed: the agent can only dial whatever daemon's
# host port we publish, period.
# --- pipelock ---------------------------------------------
pp = plan.proxy_plan
volumes += [
(str(pp.yaml_path), "/etc/pipelock.yaml", True),
(str(pp.ca_cert_host_path), PIPELOCK_CA_CERT_IN_CONTAINER, True),
(str(pp.ca_key_host_path), PIPELOCK_CA_KEY_IN_CONTAINER, True),
]
# --- egress -----------------------------------------------
ep = plan.egress_plan
if ep.routes:
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}")
env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}")
volumes += [
(str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True),
(str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True),
(str(ep.pipelock_ca_host_path), EGRESS_PIPELOCK_CA_IN_CONTAINER, True),
]
# Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv.
for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env)
# --- git-gate ---------------------------------------------
extra_hosts: list[str] = []
gp = plan.git_gate_plan
if gp.upstreams:
daemons.append("git-gate")
volumes += [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, True),
]
for u in gp.upstreams:
keypath = expand_tilde(u.identity_file)
volumes.append((
keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
True,
))
# --- supervise --------------------------------------------
sp = plan.supervise_plan
if sp is not None:
daemons.append("supervise")
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI +
# macOS networking. The HTTP/HTTPS chokepoint is whichever
# daemon's port we publish: egress when routes are declared
# (token injection first, then forwards to bundle-internal
# pipelock), pipelock otherwise.
if ep.routes:
ports_to_publish: list[int] = [_EGRESS_PORT]
else:
ports_to_publish = [_PIPELOCK_PORT]
if gp.upstreams:
ports_to_publish.append(_GIT_GATE_PORT)
if sp is not None:
ports_to_publish.append(_SUPERVISE_PORT)
return _bundle.BundleLaunchSpec(
slug=plan.slug,
network_name=network,
subnet=plan.bundle_subnet,
gateway=plan.bundle_gateway,
bundle_ip=plan.bundle_ip,
daemons_csv=",".join(daemons),
environment=tuple(env),
volumes=tuple(volumes),
ports_to_publish=tuple(ports_to_publish),
publish_host_ip=loopback_ip,
)
def _resolve_token_env(
plan: SmolmachinesBottlePlan, host_env: object
) -> dict[str, str]:
"""Resolve the egress token env-var values from the host's
environ so they reach the bundle's process env via docker's
`-e NAME` inheritance. Empty when no routes declare auth."""
ep = plan.egress_plan
if not ep.routes:
return {}
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
@@ -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 bot_bottle/backend/docker/pipelock.py. # pipelock image pin in claude_bottle/backend/docker/pipelock.py.
REGISTRY_IMAGE = os.environ.get( REGISTRY_IMAGE = os.environ.get(
"BOT_BOTTLE_REGISTRY_IMAGE", "CLAUDE_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(
"BOT_BOTTLE_CRANE_IMAGE", "CLAUDE_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"bot-bottle-registry-net-{session_id}" network = f"claude-bottle-registry-net-{session_id}"
registry_name = f"bot-bottle-registry-{session_id}" registry_name = f"claude-bottle-registry-{session_id}"
subprocess.run( subprocess.run(
["docker", "network", "create", network], ["docker", "network", "create", network],
@@ -45,7 +45,6 @@ alias gets handed to a new bottle."""
from __future__ import annotations from __future__ import annotations
import fcntl
import json import json
import os import os
import platform import platform
@@ -84,14 +83,6 @@ _POOL_START = 16
_POOL_END = 31 # inclusive _POOL_END = 31 # inclusive
# File lock that serialises concurrent allocate() calls so two
# simultaneous launches can't read the same docker state and claim
# the same alias. Narrowed to the allocate() call itself; docker run
# runs after the lock is released. Once the container is running it
# appears in docker state and future allocate() calls will see it.
_ALLOC_LOCK_PATH = Path.home() / ".cache" / "bot-bottle" / "smolmachines.lock"
# Loopback aliases pool: 127.0.0.<start>..127.0.0.<end>. # Loopback aliases pool: 127.0.0.<start>..127.0.0.<end>.
def _pool_addresses() -> list[str]: def _pool_addresses() -> list[str]:
return [f"127.0.0.{i}" for i in range(_POOL_START, _POOL_END + 1)] return [f"127.0.0.{i}" for i in range(_POOL_START, _POOL_END + 1)]
@@ -119,7 +110,7 @@ def ensure_pool() -> None:
) )
for ip in missing: for ip in missing:
result = subprocess.run( result = subprocess.run(
["sudo", "-p", "bot-bottle (loopback alias): ", ["sudo", "-p", "claude-bottle (loopback alias): ",
"ifconfig", "lo0", "alias", f"{ip}/32", "up"], "ifconfig", "lo0", "alias", f"{ip}/32", "up"],
check=False, check=False,
) )
@@ -188,20 +179,9 @@ def allocate(slug: str) -> str:
On non-macOS the whole `127.0.0.0/8` is loopback by default; On non-macOS the whole `127.0.0.0/8` is loopback by default;
`127.0.0.1` is fine to share and we skip the alias dance. `127.0.0.1` is fine to share and we skip the alias dance.
This still returns a deterministic address so launch.py's This still returns a deterministic address so launch.py's
callers don't have to branch on platform. callers don't have to branch on platform."""
An exclusive file lock serialises concurrent calls so two
simultaneous launches don't read the same docker state and
claim the same alias."""
if not _is_macos(): if not _is_macos():
return "127.0.0.1" return "127.0.0.1"
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_ALLOC_LOCK_PATH, "w") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
return _allocate_locked()
def _allocate_locked() -> str:
in_use = _aliases_in_use() in_use = _aliases_in_use()
for ip in _pool_addresses(): for ip in _pool_addresses():
if ip not in in_use: if ip not in in_use:
@@ -235,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=bot-bottle-sidecars-"], "--filter", "name=claude-bottle-sidecars-"],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
@@ -0,0 +1,237 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
Resolves the per-bottle docker subnet + bundle IP, builds the
agent's docker image from the repo Dockerfile, converts it into a
`.smolmachine` artifact via an ephemeral local registry (smolvm's
crane backend only reads registry refs), and assembles the guest
env. The `.smolmachine` is cached under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
ID so Dockerfile changes invalidate the cache automatically.
No VM bringup — that's `launch.launch`'s job."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
from ...backend import BottleSpec
from ...backend.docker import util as docker_mod
from ...backend.docker.bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
egress_state_dir,
git_gate_state_dir,
pipelock_state_dir,
supervise_state_dir,
write_metadata,
)
from ...egress import Egress
from ...git_gate import GitGate
from ...pipelock import PipelockProxy
from ...supervise import Supervise
from . import smolvm as _smolvm
from .bottle_plan import SmolmachinesBottlePlan
from .local_registry import crane_push_tarball, ephemeral_registry
from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Repo root, used as the `docker build` context for the agent image.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
# Per-host cache for `smolvm pack create` outputs. Keyed by the
# image ref so re-prepares for the same image hit the cache
# (pack create is idempotent on the smolvm side but takes several
# seconds even when no layer is fetched).
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "claude-bottle" / "smolmachines"
# Gateway ports the bundle exposes inside its container — pipelock
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent
# inside the smolvm guest dials these on the bundle's pinned IP.
_BUNDLE_PIPELOCK_PORT = 8888
_BUNDLE_GIT_GATE_PORT = 9418
_BUNDLE_SUPERVISE_PORT = 9100
def resolve_plan(
spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
"""Materialize the smolmachines plan. The bundle's docker
subnet + pinned IP are derived from the slug; the agent's
`.smolmachine` artifact is built (or cache-hit) here so
launch's `machine create --from` boots without a registry
pull. Per-bottle guest env + the TSI allow_cidrs land on the
plan for launch to pass straight through to
`machine create` flags."""
smolmachines_preflight()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
slug = spec.identity or bottle_identity(spec.agent_name)
# Record minimal metadata so `cli.py resume` can recover the
# slug. Same schema as the docker backend.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
# No compose project for smolmachines bottles; chunk 4
# will give dashboard discovery a backend-specific path.
compose_project="",
))
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
# Agent's env: the prepare-time view doesn't yet know the
# host loopback ports the bundle's daemons get published on
# (those come from docker AFTER `docker run` returns), so
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are
# populated in launch.py and stamped onto guest_env there.
# What we set here is the part that doesn't depend on
# bundle bringup — bottle.env literals, the empty-NO_PROXY
# safe default, and the TLS trust env trio
# (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE)
# pointing at Debian's update-ca-certificates output bundle.
guest_env: dict[str, str] = {
**bottle.env,
"NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
}
# Inner Plans for the four bundle daemons. The ABCs are
# platform-neutral — `.prepare()` writes config files + returns
# a Plan dataclass with no backend-specific assumptions. State
# dirs are still keyed by slug under the docker backend's
# bottle_state layout (shared on-host convention; not a docker
# dependency).
pipelock_dir = pipelock_state_dir(slug)
pipelock_dir.mkdir(parents=True, exist_ok=True)
proxy_plan = PipelockProxy().prepare(bottle, slug, pipelock_dir)
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare(bottle, slug, egress_dir)
# Claude-code refuses to start without *something* it
# recognises as a credential. When the bottle has an egress
# route carrying the `claude_code_oauth` role marker, egress
# strips + re-injects the real Authorization header on the
# outbound leg using a token held in egress's own environ — so
# the agent gets a non-secret placeholder here (matches the
# docker backend's forwarded_env logic in
# claude_bottle/backend/docker/prepare.py).
if any("claude_code_oauth" in r.roles for r in egress_plan.routes):
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
supervise_plan = None
if bottle.supervise:
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = Supervise().prepare(slug, supervise_dir)
# Prompt file is always written (mode 0o600) so the in-VM
# path always exists. Content is the agent's `prompt`
# field (markdown body) — empty for agents with no prompt.
# claude-code reads it via --append-system-prompt-file only
# when non-empty, but the file must exist either way to
# match the docker backend's contract.
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
agent = manifest.agents[spec.agent_name]
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
machine_name = f"claude-bottle-{slug}"
# Build the agent image from the repo Dockerfile (shared with
# the docker backend, layer-cached) and convert it into a
# `.smolmachine` artifact via an ephemeral local registry. The
# CLAUDE_BOTTLE_IMAGE env var match the docker backend's
# resolve_plan default so both backends use the same image when
# one is built.
agent_image_ref = os.environ.get(
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest"
)
agent_from_path = _ensure_smolmachine(agent_image_ref)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
bundle_subnet=subnet,
bundle_gateway=gateway,
bundle_ip=bundle_ip,
machine_name=machine_name,
agent_from_path=agent_from_path,
guest_env=guest_env,
prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
)
def _ensure_smolmachine(image_ref: str) -> Path:
"""Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
ID (so a Dockerfile change automatically invalidates the cache).
Returns the `.smolmachine.smolmachine` sidecar path — that's
the file `machine create --from` consumes (pack create produces
a launcher binary at `.smolmachine` plus the sidecar alongside
it; the sidecar is the actual artifact).
Conversion path: `docker build` (the existing layer cache
makes no-change rebuilds cheap) → `docker save` to a tarball
→ spin up an ephemeral registry on a private docker network →
`crane push --insecure` from a one-shot container on the same
network → `smolvm pack create --image localhost:<host port>/...`
→ tear down the registry + network. The crane push detour
sidesteps the Docker-Desktop daemon's HTTPS preference for
non-loopback registries — see the `local_registry` module
docstring for the gory details.
Each pack-create costs several seconds even on a hot cache,
so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
# keep filenames manageable, long enough to make collisions
# astronomically unlikely.
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
binary = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine"
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if sidecar.is_file():
return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
docker_mod.save(image_ref, str(tarball))
try:
with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}"
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary)
finally:
# Tarball is ~500MB-1GB for the agent image; reclaim once
# the smolmachine artifact exists. The artifact itself is
# the long-lived cache entry.
tarball.unlink(missing_ok=True)
return sidecar
@@ -15,27 +15,49 @@ flag exists; the VM init is root), so we don't need the explicit
from __future__ import annotations from __future__ import annotations
import time import hashlib
import ssl
from pathlib import Path
from ....log import die from ....log import die, info
from ...util import ( from ...docker.provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
AGENT_CA_BUNDLE,
AGENT_CA_PATH,
log_ca_fingerprint,
select_ca_cert,
)
from .. import smolvm as _smolvm from .. import smolvm as _smolvm
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
_SIGKILL_EXIT = 128 + 9 def _select_ca_cert(plan: SmolmachinesBottlePlan) -> tuple[Path, str]:
"""Pick the CA cert (and a short label for the log line) that
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
wins when the bottle declares any routes; else pipelock.
The launch step minted both CAs (pipelock always; egress when
routes are declared) and stored their host paths back into the
inner Plans via `dataclasses.replace`. If those paths are empty
here something has gone wrong in launch's bringup."""
if plan.egress_plan.routes:
cert = plan.egress_plan.mitmproxy_ca_cert_only_host_path
if cert == Path() or not cert.is_file():
die(
f"egress CA cert missing at {cert or '(empty)'}; "
f"launch must have called egress_tls_init and "
f"re-bound the plan before provision"
)
return cert, "egress"
cert = plan.proxy_plan.ca_cert_host_path
if not cert or not cert.is_file():
die(
f"pipelock CA cert missing at {cert or '(empty)'}; "
f"launch must have called pipelock_tls_init and re-bound "
f"the plan before provision"
)
return cert, "pipelock"
def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
"""Copy the agent-facing CA cert into the guest, rebuild the """Copy the agent-facing CA cert into the guest, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the smolvm guest is up.""" `BottleBackend.provision` after the smolvm guest is up."""
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) cert_host_path, label = _select_ca_cert(plan)
_smolvm.machine_cp(str(cert_host_path), f"{target}:{AGENT_CA_PATH}") _smolvm.machine_cp(str(cert_host_path), f"{target}:{AGENT_CA_PATH}")
# Mode 0644 — readable to non-root tools in the guest. # Mode 0644 — readable to non-root tools in the guest.
@@ -45,16 +67,17 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python # REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
# `requests` / libraries that don't load the system bundle. # `requests` / libraries that don't load the system bundle.
# #
r = _install_ca(target) # chown + chmod + update-ca-certificates run in one
if r.returncode == _SIGKILL_EXIT: # `sh -c` so we only pay one machine_exec round trip; the
# smolvm/libkrun can SIGKILL an otherwise-normal exec # `&&` chaining surfaces the first failure as the return
# during early-VM provisioning. `update-ca-certificates` # code.
# is idempotent, so retry the same install once after a r = _smolvm.machine_exec(target, [
# short settle delay before treating it as fatal. "sh", "-c",
time.sleep(1.0) f"chown root:root {AGENT_CA_PATH} && "
r = _install_ca(target) f"chmod 644 {AGENT_CA_PATH} && "
f"update-ca-certificates",
if r.returncode != 0: ])
if r.returncode != 0 or "1 added" not in (r.stdout or ""):
# update-ca-certificates not adding our cert is fatal — # update-ca-certificates not adding our cert is fatal —
# claude-code's TLS handshake against the egress-MITM'd # claude-code's TLS handshake against the egress-MITM'd
# api.anthropic.com would fail downstream. Bail early # api.anthropic.com would fail downstream. Bail early
@@ -67,27 +90,15 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
f"stderr={(r.stderr or '').strip()!r}" f"stderr={(r.stderr or '').strip()!r}"
) )
log_ca_fingerprint(cert_host_path, label) # Stdlib SHA-256 of the cert's DER bytes — the standard
# fingerprint form. Never the private key.
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
def _install_ca(target: str) -> _smolvm.SmolvmRunResult: fingerprint = hashlib.sha256(der).hexdigest()
# chown + chmod + update-ca-certificates + bundle info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
# verification run in one `sh -c` so we only pay one
# machine_exec round trip; the `&&` chaining surfaces the
# first failure as the return code. The verify check is more
# stable than requiring "1 added" in stdout: a retry after a
# partially-completed first run may legitimately report "0
# added" while the cert is already installed.
return _smolvm.machine_exec(target, [
"sh", "-c",
f"chown root:root {AGENT_CA_PATH} && "
f"chmod 644 {AGENT_CA_PATH} && "
f"update-ca-certificates && "
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
])
# Re-exported for the launch/provision_ca caller + tests. The path # Re-exported for the launch/provision_ca caller + tests. The path
# constants live in the shared `backend.util` (Debian's # constants come from the docker module because they're tied to
# `update-ca-certificates` layout is the same in both backends). # Debian's `update-ca-certificates` layout same in both backends
# since both guest images are Debian-family.
__all__ = ["AGENT_CA_BUNDLE", "AGENT_CA_PATH", "provision_ca"] __all__ = ["AGENT_CA_BUNDLE", "AGENT_CA_PATH", "provision_ca"]
@@ -1,24 +1,21 @@
"""Git provisioning inside a running smolmachines bottle """Git provisioning inside a running smolmachines bottle
(PRD 0023 chunk 4d). (PRD 0023 chunk 4d).
Three concerns, all about git in the agent: Two concerns, both about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that 1. If --cwd was passed AND the host cwd has a .git, copy that
.git into the planned guest workspace so the agent operates on .git into /home/node/workspace/.git so the agent operates on
the user's repo. the user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a 2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation ~/.gitconfig with insteadOf rules so every git operation
against a declared upstream transparently hits the per-bottle against a declared upstream transparently hits the per-bottle
git-gate. The gate mirrors the upstream in both directions, git-gate. The gate mirrors the upstream in both directions,
so URL rewriting is symmetric. so URL rewriting is symmetric.
3. If the bottle declares `git.user` (issue #86), set
`git config --global user.{name,email}` inside the guest so
the agent's commits are attributed to that identity.
Differs from `backend.docker.provision.git` in one address detail: Differs from `backend.docker.provision.git` in one address detail:
the TSI-allowlisted guest can only reach the bundle's pinned IP the TSI-allowlisted guest can only reach the bundle's pinned IP
(no DNS resolver in the /32 allowlist), so the insteadOf URLs (no DNS resolver in the /32 allowlist), so the insteadOf URLs
are `http://<bundle_ip>:<port>/<name>.git` rather than the are `git://<bundle_ip>:<port>/<name>.git` rather than the
docker backend's `git://git-gate/<name>.git`. The render itself docker backend's `git://git-gate/<name>.git`. The render itself
is the shared `git_gate_render_gitconfig` on the platform-neutral is the shared `git_gate_render_gitconfig` on the platform-neutral
git_gate module.""" git_gate module."""
@@ -36,44 +33,41 @@ 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
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's # CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different # CLAUDE_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("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) return os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
"""Set up git inside the guest. Runs all three subcases; each """Set up git inside the guest. Runs both subcases; each
no-ops when its condition isn't met.""" no-ops when its condition isn't met."""
_provision_cwd_git(plan, target) _provision_cwd_git(plan, target)
_provision_git_gate_config(plan, target) _provision_git_gate_config(plan, target)
_provision_git_user(plan, target)
def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None: def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy """If --cwd was set and the host cwd has a .git directory, copy
it into <guest_home>/workspace/.git and fix ownership. No-op it into <guest_home>/workspace/.git and fix ownership. No-op
otherwise.""" otherwise."""
workspace = plan.workspace_plan if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()):
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
return return
guest_workspace_git = f"{workspace.guest_path}/.git" guest_workspace_git = f"{_guest_home()}/workspace/.git"
host_git = str(workspace.host_path / ".git") info(f"copying {plan.spec.user_cwd}/.git -> {target}:{guest_workspace_git}")
info(f"copying {host_git} -> {target}:{guest_workspace_git}")
# mkdir -p the workspace dir so `machine cp` lands the .git # mkdir -p the workspace dir so `machine cp` lands the .git
# directly there even on first-time bottles. # directly there even on first-time bottles.
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path]) _smolvm.machine_exec(target, ["mkdir", "-p", f"{_guest_home()}/workspace"])
_smolvm.machine_cp( _smolvm.machine_cp(
host_git, f"{target}:{guest_workspace_git}", f"{plan.spec.user_cwd}/.git", f"{target}:{guest_workspace_git}",
) )
# `machine cp` lands files as root; the agent runs as node so # `machine cp` lands files as root; the agent runs as node so
# the workspace tree must be chowned over. # the workspace tree must be chowned over.
_smolvm.machine_exec( _smolvm.machine_exec(
target, ["chown", "-R", workspace.owner, guest_workspace_git], target, ["chown", "-R", "node:node", guest_workspace_git],
) )
@@ -84,14 +78,12 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
if not bottle.git: if not bottle.git:
return return
# `<loopback alias>:<host port>` form: the bundle's git-gate # `127.0.0.1:<host port>` form: the bundle's git-gate port
# HTTP port is published on host loopback at launch time so # is published on host loopback at launch time so the
# the smolvm guest (which can only reach macOS networking via # smolvm guest (which can only reach macOS networking via
# TSI, not the docker bridge IP) can dial it. launch.py # TSI, not the docker bridge IP) can dial it. launch.py
# populates `plan.agent_git_gate_host` after bundle bringup. # populates `plan.agent_git_gate_host` after bundle bringup.
content = git_gate_render_gitconfig( content = git_gate_render_gitconfig(bottle.git, plan.agent_git_gate_host)
bottle.git, plan.agent_git_gate_host, scheme="http",
)
guest_gitconfig = f"{_guest_home()}/.gitconfig" guest_gitconfig = f"{_guest_home()}/.gitconfig"
# Stage the file under the plan's stage_dir so `machine cp` # Stage the file under the plan's stage_dir so `machine cp`
@@ -109,37 +101,3 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}") _smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}")
_smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig]) _smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig])
_smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig]) _smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig])
def _provision_git_user(
plan: SmolmachinesBottlePlan, target: str,
) -> None:
"""Apply `git config --global user.{name,email}` inside the
guest as the node user so --global lands in the same
`/home/node/.gitconfig` that `_provision_git_gate_config`
writes to. No-op when the bottle didn't declare `git.user`.
Runs via `runuser -u node --`; HOME is forced via smolvm's
`-e` flag because runuser (without -l) inherits root's
HOME=/root, which would put --global in the wrong file."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
gu = bottle.git_user
if gu.is_empty():
return
env = {"HOME": _guest_home(), "USER": "node"}
if gu.name:
info(f"git config --global user.name = {gu.name!r}")
_smolvm.machine_exec(
target,
["runuser", "-u", "node", "--",
"git", "config", "--global", "user.name", gu.name],
env=env,
)
if gu.email:
info(f"git config --global user.email = {gu.email!r}")
_smolvm.machine_exec(
target,
["runuser", "-u", "node", "--",
"git", "config", "--global", "user.email", gu.email],
env=env,
)
@@ -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.
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's # CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
# BOT_BOTTLE_CONTAINER_HOME knob. # CLAUDE_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("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) guest_home = os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt" in_guest_prompt_path = f"{guest_home}/.claude-bottle-prompt.txt"
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}") _smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
# machine cp lands as root, source's 0o600 mode is preserved — # machine cp lands as root, source's 0o600 mode is preserved —
@@ -19,7 +19,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
# In-guest path mirrors the docker backend's claude-skills # In-guest path mirrors the docker backend's claude-skills
# convention (~/.claude/skills/<name>/) under the node user's # convention (~/.claude/skills/<name>/) under the node user's
# home — same path as the real bot-bottle image's # home — same path as the real claude-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(
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR, "CLAUDE_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
) )
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir]) _smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
@@ -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 (`bot-bottle-sidecars:latest` by default). Same image (`claude-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,29 +29,22 @@ from pathlib import Path
from typing import Sequence from typing import Sequence
from ...log import die, warn from ...log import die, warn
from ..docker import util as docker_mod from ..docker.sidecar_bundle import SIDECAR_BUNDLE_IMAGE
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:
"""`bot-bottle-bundle-<slug>` — distinct from the docker """`claude-bottle-bundle-<slug>` — distinct from the docker
backend's `bot-bottle-net-<slug>` so a smolmachines bottle backend's `claude-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"bot-bottle-bundle-{slug}" return f"claude-bottle-bundle-{slug}"
def bundle_container_name(slug: str) -> str: def bundle_container_name(slug: str) -> str:
"""`bot-bottle-sidecars-<slug>` — same name shape the docker """`claude-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"bot-bottle-sidecars-{slug}" return f"claude-bottle-sidecars-{slug}"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -66,7 +59,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 BOT_BOTTLE_SIDECAR_DAEMONS. The # Daemon subset CSV for CLAUDE_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"
@@ -92,21 +85,6 @@ 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
@@ -163,7 +141,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"BOT_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}", "-e", f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
] ]
for entry in spec.environment: for entry in spec.environment:
argv += ["-e", entry] argv += ["-e", entry]
@@ -27,13 +27,11 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Mapping, Sequence from typing import Mapping, Sequence
_SMOLVM = "smolvm" _SMOLVM = "smolvm"
@@ -199,34 +197,6 @@ def machine_exec(
) )
def wait_exec_ready(name: str, *, timeout: float = 5.0) -> None:
"""Poll `machine exec true` until exit 0 or `timeout` elapses.
Replaces `time.sleep(1.5)` after `machine_start`: libkrun's exec
channel needs a brief warm-up before back-to-back exec calls are
safe. Polling exits as soon as the channel is ready and fails
loudly if the VM never responds."""
deadline = time.monotonic() + timeout
delay = 0.1
while time.monotonic() < deadline:
r = machine_exec(name, ["true"])
if r.returncode == 0:
return
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(delay, remaining))
delay = min(delay * 2, 0.5)
argv = ["smolvm", "machine", "exec", "--name", name, "--", "true"]
raise SmolvmError(
argv,
subprocess.CompletedProcess(
args=argv, returncode=-1, stdout="",
stderr=f"exec channel not ready after {timeout:.0f}s — VM may have failed to boot.",
),
)
def machine_cp(src: str, dst: str) -> None: def machine_cp(src: str, dst: str) -> None:
"""`smolvm machine cp SRC DST`. Path syntax: `machine:path` to """`smolvm machine cp SRC DST`. Path syntax: `machine:path` to
reference a path inside the VM, bare path for the host. Both reference a path inside the VM, bare path for the host. Both
@@ -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(
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on " "CLAUDE_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"
) )
+18
View File
@@ -0,0 +1,18 @@
"""Cross-backend utility helpers — host-side primitives shared by
every backend implementation. Backend-specific helpers live one level
deeper (e.g. claude_bottle/backend/docker/util.py)."""
from __future__ import annotations
import os
from ..log import die
def host_skill_dir(name: str) -> str:
"""Return the host-side path for a named skill:
`$HOME/.claude/skills/<name>`. Dies if HOME is unset."""
home = os.environ.get("HOME")
if not home:
die("HOME not set")
return f"{home}/.claude/skills/{name}"
@@ -7,8 +7,7 @@ from __future__ import annotations
import sys import sys
from ..log import Die, die, error from ..log import Die, die
from ..manifest import ManifestError
from ._common import PROG from ._common import PROG
from . import list as _list_mod from . import list as _list_mod
from .cleanup import cmd_cleanup from .cleanup import cmd_cleanup
@@ -36,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 bot-bottle containers\n") sys.stderr.write(" cleanup stop and remove all active claude-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 bot-bottle.json\n") sys.stderr.write(" init interactively create a new agent and add it to claude-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")
@@ -64,11 +63,6 @@ def main(argv: list[str] | None = None) -> int:
die(f"unknown command: {command}") die(f"unknown command: {command}")
try: try:
return handler(rest) or 0 return handler(rest) or 0
except ManifestError as e:
# Manifest/config problems surface as a catchable exception;
# print the reason and exit non-zero (same UX die() used to give).
error(str(e))
return 1
except Die as e: except Die as e:
return e.code if isinstance(e.code, int) else 1 return e.code if isinstance(e.code, int) else 1
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -1,4 +1,4 @@
"""cleanup: stop and remove all orphaned bot-bottle resources. """cleanup: stop and remove all orphaned claude-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 ~/.bot-bottle/state/<identity>` want to `resume`. Manual `rm -rf ~/.claude-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 bot-bottle resources to clean up") info("no claude-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"bot-bottle: {message} [y/N] ") sys.stderr.write(f"claude-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")
@@ -21,13 +21,11 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
import traceback
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone 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,
@@ -53,8 +51,8 @@ from ..backend.docker.pipelock_apply import (
parse_allowlist_content, parse_allowlist_content,
render_allowlist_content, render_allowlist_content,
) )
from ..log import Die, error, info from ..log import info
from ..manifest import Manifest, ManifestError from ..manifest import Manifest
from ..supervise import ( from ..supervise import (
ACTION_OPERATOR_EDIT, ACTION_OPERATOR_EDIT,
COMPONENT_FOR_TOOL, COMPONENT_FOR_TOOL,
@@ -75,8 +73,8 @@ from ..supervise import (
) )
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
from .start import ( from .start import (
attach_agent, attach_claude,
capture_claude_session_state, capture_session_state,
prepare_with_preflight, prepare_with_preflight,
settle_state, settle_state,
) )
@@ -121,10 +119,10 @@ def _approval_status(qp: QueuedProposal, verb: str) -> str:
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
"""Walk ~/.bot-bottle/queue/* and collect pending proposals """Walk ~/.claude-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.bot_bottle_root() / "queue" queue_root = _supervise.claude_bottle_root() / "queue"
if not queue_root.is_dir(): if not queue_root.is_dir():
return [] return []
out: list[QueuedProposal] = [] out: list[QueuedProposal] = []
@@ -175,13 +173,6 @@ def approve(
qp.proposal.bottle_slug, file_to_apply, qp.proposal.bottle_slug, file_to_apply,
) )
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project:
raise CapabilityApplyError(
"capability-block remediation is not supported for smolmachines "
"bottles. Reject this proposal or handle the capability change "
"manually, then restart the bottle."
)
diff_before, diff_after = apply_capability_change( diff_before, diff_after = apply_capability_change(
qp.proposal.bottle_slug, file_to_apply, qp.proposal.bottle_slug, file_to_apply,
) )
@@ -376,6 +367,8 @@ 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:
@@ -461,13 +454,9 @@ 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,
empty_message, "(no agents match filter)",
box_w - 4, curses.A_DIM, box_w - 4, curses.A_DIM,
) )
else: else:
@@ -553,7 +542,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 BOT_BOTTLE_BACKEND beforehand smolmachines without setting CLAUDE_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:
@@ -647,40 +636,37 @@ def _bottle_for_slug(
) -> tuple["object", str]: ) -> tuple["object", str]:
"""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 bottle from the persisted handle directly. Otherwise synthesize a `DockerBottle` from the
metadata. The backend field in metadata (PRD 0040) selects Docker container name `claude-bottle-<slug>`. For synthesized bottles
or smolmachines; unknown or missing metadata defaults to Docker. the prompt-file path comes from the manifest's agent if we can
resolve it via metadata.json + the loaded manifest; otherwise
the re-attach runs without `--append-system-prompt-file`.
Returns the empty string for prompt_path_hint when we omit the Returns the empty string for prompt_path_hint when we omit the
flag the caller passes None to DockerBottle in that case.""" flag the caller passes None to DockerBottle in that case."""
from ..backend.docker.bottle import DockerBottle from ..backend.docker.bottle import DockerBottle
from ..backend.docker.bottle_state import read_metadata from ..backend.docker.bottle_state import read_metadata
from ..backend.smolmachines.bottle import SmolmachinesBottle
if slug in bottles: if slug in bottles:
_cm, bottle, _identity = bottles[slug] _cm, bottle, _identity = bottles[slug]
return bottle, "" return bottle, ""
instance_name = f"bot-bottle-{slug}" # The container hosting the agent's claude process is named
# `claude-bottle-<slug>` — set by the compose renderer
# (no service suffix on the agent service, by design).
container_name = f"claude-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(
"BOT_BOTTLE_CONTAINER_HOME", "/home/node", "CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node",
) )
prompt_path = f"{container_home}/.bot-bottle-prompt.txt" prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
backend = metadata.backend if metadata is not None else "" synth = DockerBottle(
if backend == "smolmachines": container=container_name,
synth: object = SmolmachinesBottle( teardown=lambda: None,
instance_name, prompt_path_in_container=prompt_path,
prompt_path=prompt_path, )
)
else:
synth = DockerBottle(
container=instance_name,
teardown=lambda: None,
prompt_path_in_container=prompt_path,
)
return synth, (prompt_path or "") return synth, (prompt_path or "")
@@ -707,7 +693,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
@@ -717,8 +703,7 @@ 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:
if getattr(bottle, "agent_provider_template", "claude") == "claude": capture_session_state(identity, exit_code=0)
capture_claude_session_state(identity, exit_code=0)
except BaseException: except BaseException:
pass pass
try: try:
@@ -728,7 +713,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 agent session) via `_ensure_right_pane`; the # agent's own claude 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:
@@ -765,7 +750,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 agent session. The dashboard remembers the # the new agent's claude 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.
@@ -776,92 +761,68 @@ def _in_tmux() -> bool:
return bool(os.environ.get("TMUX")) return bool(os.environ.get("TMUX"))
def _agent_runtime_args( def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]:
*, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude", """The argv the dashboard hands to `bottle.claude_docker_argv`
) -> list[str]: on every attach matches what `attach_claude` builds for the
"""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."""
runtime = runtime_for(agent_provider_template) args = ["--dangerously-skip-permissions"]
args = list(runtime.bypass_args)
if remote_control: if remote_control:
args.extend(runtime.remote_control_args) args.append("--remote-control")
if resume: if resume:
args.extend(runtime.resume_args) args.append("--continue")
return args return args
def _build_resume_argv_with_fallback( def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False, agent_provider_template: str = "claude", bottle, *, remote_control: bool = False,
) -> list[str]: ) -> list[str]:
"""Build a backend-exec argv that runs `claude --continue` and """Build a docker-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.
`--continue` exits non-zero when an agent has been spun up `--continue` exits non-zero when an agent has been spun up
but never typed at there's no transcript to resume. The but never typed at there's no transcript to resume. The
shell-level `||` wrapper makes that case start a fresh shell-level `||` wrapper makes that case start a fresh
session instead of crashing the pane. The trade-off: we session instead of crashing the pane. The trade-off: we
invoke `sh -c` inside the bottle, so the command is two invoke `sh -c` inside the container, so the command is two
`claude` invocations behind a tiny shell rather than one `claude` invocations behind a tiny shell rather than one
direct exec. Acceptable; the shell adds microseconds and direct exec. Acceptable; the shell adds microseconds and
the fallback only kicks in when --continue would have the fallback only kicks in when --continue would have
failed anyway. failed anyway."""
base_args = ["--dangerously-skip-permissions"]
Works across backends because `bottle.agent_argv` always if remote_control:
surfaces the `claude` token preceded by the backend's exec base_args.append("--remote-control")
framing (docker: `docker exec -it <c>`; smolmachines: base_docker = bottle.claude_docker_argv(base_args)
`smolvm machine exec --name <m> -- runuser -u node --`). # Split docker-prefix from the claude-and-args tail so we
Splitting at `claude` keeps the framing as the prefix and # can compose `<claude…> --continue || <claude…>` inside
wraps just the agent tail in `sh -c`.""" # `sh -c`. The `claude` token is the marker.
if agent_provider_template != "claude": claude_idx = base_docker.index("claude")
return bottle.agent_argv( prefix = base_docker[:claude_idx]
_agent_runtime_args( claude_cmd = " ".join(shlex.quote(a) for a in base_docker[claude_idx:])
resume=True,
remote_control=remote_control,
agent_provider_template=agent_provider_template,
)
)
base_args = _agent_runtime_args(
resume=False,
remote_control=remote_control,
agent_provider_template=agent_provider_template,
)
base_exec = bottle.agent_argv(base_args)
# Split exec-framing prefix from the agent-and-args tail so
# we can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The provider command token is the marker.
command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command)
agent_idx = base_exec.index(command)
prefix = base_exec[:agent_idx]
agent_cmd = " ".join(shlex.quote(a) for a in base_exec[agent_idx:])
resume_args = " ".join(
shlex.quote(a) for a in runtime_for(agent_provider_template).resume_args
)
return [ return [
*prefix, *prefix,
"sh", "-c", "sh", "-c",
f"{agent_cmd} {resume_args} || {agent_cmd}", f"{claude_cmd} --continue || {claude_cmd}",
] ]
def _build_split_pane_argv(agent_argv: list[str]) -> list[str]: def _build_split_pane_argv(docker_argv: list[str]) -> list[str]:
"""Pure helper: wrap a backend-exec argv with `tmux split-window """Pure helper: wrap a docker-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
`respawn-pane` calls.""" `respawn-pane` calls."""
return [ return [
"tmux", "split-window", "-h", "tmux", "split-window", "-h",
"-P", "-F", "#{pane_id}", "-P", "-F", "#{pane_id}",
*agent_argv, *docker_argv,
] ]
def _build_respawn_pane_argv(pane_id: str, agent_argv: list[str]) -> list[str]: def _build_respawn_pane_argv(pane_id: str, docker_argv: list[str]) -> list[str]:
"""Pure helper: wrap a backend-exec argv with `tmux respawn-pane """Pure helper: wrap a docker-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, *agent_argv] return ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv]
@contextlib.contextmanager @contextlib.contextmanager
@@ -965,7 +926,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 agent session left dashboard-owned agent is stopped no claude 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):
@@ -1005,7 +966,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 agent sessions AND by used by `_attach_in_tmux` for claude 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."""
@@ -1050,18 +1011,14 @@ def _attach_via_handoff(
`_attach_in_tmux` when tmux misbehaves).""" `_attach_in_tmux` when tmux misbehaves)."""
curses.endwin() curses.endwin()
try: try:
agent_provider_template = getattr(bottle, "agent_provider_template", "claude") exit_code = attach_claude(
exit_code = attach_agent( bottle, remote_control=False, resume=resume,
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}] agent session ended (exit {exit_code})" return f"[{slug}] claude session ended (exit {exit_code})"
def _attach_in_tmux( def _attach_in_tmux(
@@ -1080,28 +1037,21 @@ 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 agent immediately. respawn so the operator is dropped into claude 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
# agent instead of crashing. # claude instead of crashing.
agent_argv = _build_resume_argv_with_fallback( docker_argv = _build_resume_argv_with_fallback(bottle)
bottle, agent_provider_template=agent_provider_template,
)
else: else:
agent_provider_template = getattr(bottle, "agent_provider_template", "claude") docker_argv = bottle.claude_docker_argv(
agent_argv = bottle.agent_argv( _claude_runtime_args(resume=False),
_agent_runtime_args(
resume=False,
agent_provider_template=agent_provider_template,
),
) )
pane_id = _ensure_right_pane(tmux_state, agent_argv) pane_id = _ensure_right_pane(tmux_state, docker_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
@@ -1134,7 +1084,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 agent session opens in the `tmux_state` provided) the claude 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
@@ -1142,7 +1092,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 agent instead of the dashboard. # so keypresses land in claude 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,
@@ -1160,15 +1110,13 @@ 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 agent session into the right pane (in-tmux) or the first claude 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 /
@@ -1196,7 +1144,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="bot-bottle-stage.")) stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
try: try:
plan, identity = prepare_with_preflight( plan, identity = prepare_with_preflight(
spec, spec,
@@ -1250,20 +1198,14 @@ def _new_agent_flow(
raise raise
bottles[plan.slug] = (cm, bottle, identity) bottles[plan.slug] = (cm, bottle, identity)
# Foreground handoff: the agent owns the terminal until exit, # Foreground handoff: claude owns the terminal until exit,
# then we restore curses. # then we restore curses.
try: try:
agent_provider_template = getattr(plan, "agent_provider_template", "claude") exit_code = attach_claude(bottle, remote_control=False)
exit_code = attach_agent( capture_session_state(identity, exit_code)
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}] agent session ended (exit {exit_code})" return f"[{plan.slug}] claude 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
@@ -1288,57 +1230,9 @@ def cmd_dashboard(argv: list[str]) -> int:
curses.wrapper(_main_loop) curses.wrapper(_main_loop)
except KeyboardInterrupt: except KeyboardInterrupt:
return 130 return 130
except Die as e:
# die() printed the reason to stderr, but that happened while
# curses owned the terminal — the text landed on the alternate
# screen and was wiped when the terminal was restored. Re-surface
# it now that we're back on the normal screen.
if e.message:
error(e.message)
else:
error("dashboard exited on a fatal error (no detail captured).")
return e.code if isinstance(e.code, int) else 1
except Exception as e:
# Any other crash inside the TUI. The traceback would otherwise
# vanish with the alternate screen, so persist it and tell the
# operator where to look.
log_path = _write_crash_log(e)
error(f"dashboard crashed: {type(e).__name__}: {e}")
error(f"full traceback written to {log_path}")
return 1
return 0 return 0
def _write_crash_log(exc: BaseException) -> Path:
"""Persist `exc`'s traceback to a stable file under ~/.bot-bottle/
and return its path.
The dashboard runs under curses, so a crash's stderr/traceback is
painted onto the alternate screen and lost when the terminal is
restored this leaves the operator a durable record of *why* it
died. Best-effort: falls back to a tempfile if the home dir can't
be written."""
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
body = "".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
)
entry = f"=== dashboard crash {stamp} ===\n{body}\n"
try:
log_dir = _supervise.bot_bottle_root() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / "dashboard-crash.log"
with path.open("a", encoding="utf-8") as fh:
fh.write(entry)
return path
except OSError:
fd, tmp = tempfile.mkstemp(
prefix="bot-bottle-dashboard-crash-", suffix=".log",
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(entry)
return Path(tmp)
def _list_once() -> int: def _list_once() -> int:
pending = discover_pending() pending = discover_pending()
if not pending: if not pending:
@@ -1464,19 +1358,8 @@ 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, missing_ok=True) manifest_cache[0] = Manifest.resolve(USER_CWD)
return manifest_cache[0] return manifest_cache[0]
# A malformed manifest must not take the whole dashboard down — the
# operator may just be watching running bottles. Degrade to a
# status-line warning instead. (Any non-config error propagates to
# cmd_dashboard's crash handler.)
try:
_loaded = _get_manifest()
except ManifestError as e:
status_line = f"config error: {e}"
else:
if not _loaded.bottles and not _loaded.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.
@@ -1562,9 +1445,6 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
# bottle running. # bottle running.
try: try:
manifest = _get_manifest() manifest = _get_manifest()
except ManifestError as e:
status_line = f"config error: {e}"
continue
except Exception as e: except Exception as e:
status_line = f"manifest load failed: {e}" status_line = f"manifest load failed: {e}"
continue continue
@@ -1617,7 +1497,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 agent session. If # right pane with its claude 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,
@@ -1691,7 +1571,7 @@ def _render(
h, w = stdscr.getmaxyx() h, w = stdscr.getmaxyx()
agents = agents or [] agents = agents or []
header = ( header = (
f"bot-bottle dashboard " f"claude-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"]) / "bot-bottle.json" target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
else: else:
target_file = Path(USER_CWD) / "bot-bottle.json" target_file = Path(USER_CWD) / "claude-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 bot-bottle.json") parser.add_argument("name", help="agent name defined in claude-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
@@ -31,9 +31,6 @@ def cmd_info(argv: list[str]) -> int:
f"first line: {prompt_first_line or '(empty)'}" f"first line: {prompt_first_line or '(empty)'}"
) )
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(args.name)
if identity:
info(f" git identity : {identity}")
if bottle.git: if bottle.git:
for e in bottle.git: for e in bottle.git:
info( info(
@@ -1,4 +1,4 @@
"""init: interactively create a new agent and add it to bot-bottle.json.""" """init: interactively create a new agent and add it to claude-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"]) / "bot-bottle.json" target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
else: else:
target_file = Path(USER_CWD) / "bot-bottle.json" target_file = Path(USER_CWD) / "claude-bottle.json"
print(file=sys.stderr) print(file=sys.stderr)
info(f"bot-bottle init — adding a new agent to {target_file}") info(f"claude-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'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] ' f'claude-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 bot-bottle bottles", file=sys.stderr) print("no active claude-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 ~/.bot-bottle/state/<identity>/metadata.json to recover the Reads ~/.claude-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 ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle" f"check ~/.claude-bottle/state/ or run `cli.py start` to create a new bottle"
) )
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
@@ -52,10 +52,8 @@ def cmd_resume(argv: list[str]) -> int:
user_cwd=metadata.cwd or USER_CWD, user_cwd=metadata.cwd or USER_CWD,
identity=metadata.identity, identity=metadata.identity,
) )
backend_name = metadata.backend or None
return _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=args.dry_run, dry_run=args.dry_run,
remote_control=args.remote_control, remote_control=args.remote_control,
backend_name=backend_name,
) )
@@ -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_agent`, and the public helpers `prepare_with_preflight`, `attach_claude`, and the
private orchestrator `_launch_bottle`. private orchestrator `_launch_bottle`.
""" """
@@ -18,7 +18,6 @@ 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,
@@ -47,14 +46,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: $BOT_BOTTLE_BACKEND " "backend to launch the bottle on (default: $CLAUDE_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 bot-bottle.json") parser.add_argument("name", help="agent name defined in claude-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
spec = BottleSpec( spec = BottleSpec(
@@ -89,7 +88,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` `$BOT_BOTTLE_BACKEND` `docker`). Dashboard (`None` `$CLAUDE_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.
@@ -113,13 +112,11 @@ def prepare_with_preflight(
return plan, identity return plan, identity
def attach_agent( def attach_claude(
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 the selected provider CLI inside `bottle` as an """Run claude inside `bottle` as an interactive session. Blocks
interactive session. Blocks until the session ends; returns the until the session ends; returns the claude process's exit code.
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)
@@ -131,28 +128,26 @@ def attach_agent(
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 the agent has it.""" terminal's way while claude has it."""
runtime = runtime_for(agent_provider_template)
info( info(
f"attaching interactive {agent_provider_template} session " "attaching interactive claude session "
"(Ctrl-D or 'exit' to leave; container will be removed)" "(Ctrl-D or 'exit' to leave; container will be removed)"
) )
agent_args = list(runtime.bypass_args) claude_args = ["--dangerously-skip-permissions"]
if remote_control: if remote_control:
agent_args.extend(runtime.remote_control_args) claude_args.append("--remote-control")
if resume: if resume:
agent_args.extend(runtime.resume_args) # `--continue` jumps straight to the most recent session
return bottle.exec_agent(agent_args, tty=True) # without showing the picker `--resume` would surface.
claude_args.append("--continue")
return bottle.exec_claude(claude_args, tty=True)
def capture_claude_session_state(identity: str, exit_code: int) -> None: def capture_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)
@@ -184,7 +179,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("bot-bottle: launch this agent? [y/N] ") sys.stderr.write("claude-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")
@@ -206,7 +201,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="bot-bottle-stage.")) stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
identity = "" identity = ""
try: try:
plan, identity = prepare_with_preflight( plan, identity = prepare_with_preflight(
@@ -222,12 +217,7 @@ 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:
agent_provider_template = getattr(plan, "agent_provider_template", "claude") exit_code = attach_claude(bottle, remote_control=remote_control)
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"
@@ -240,8 +230,7 @@ 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.
if agent_provider_template == "claude": capture_session_state(identity, exit_code)
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
+116 -109
View File
@@ -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
`bot_bottle/backend/docker/egress.py`). `claude_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
@@ -24,19 +24,12 @@ flow (PRD 0014) at egress and renames the MCP tool.
from __future__ import annotations from __future__ import annotations
import dataclasses
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from .egress_addon_core import Route
from .log import die from .log import die
from .manifest import Bottle
if TYPE_CHECKING:
from .manifest import Bottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
# DNS name agents will dial for the per-bottle egress sidecar. # DNS name agents will dial for the per-bottle egress sidecar.
@@ -55,30 +48,32 @@ EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressRoute(Route): class EgressRoute:
"""Host-side extension of the addon's `Route`. """One resolved route on the egress sidecar.
Inherits `host`, `path_allowlist`, `auth_scheme`, and `token_env` `host` matches the request's hostname (case-insensitive). The
from `egress_addon_core.Route` those are the fields that cross the optional `path_allowlist` constrains the URL path; empty tuple
YAML wire into the sidecar. The three fields below are host-only and means no path-level filtering. The `auth_scheme` / `token_env` /
are never serialised to the addon. `token_ref` triple is the credential-injection config; empty
strings mean "no auth injection" (the manifest's nested `auth`
block was omitted).
`token_ref` is the host env var the CLI reads at launch and forwards `token_env` is the env-var slot inside the egress container
into the container's environ under `token_env`. Routes that share a (e.g. `EGRESS_TOKEN_0`); `token_ref` is the host env var
`token_ref` coalesce to one `token_env` slot. the CLI reads at launch and forwards into the container's environ
under `token_env`. Routes that share a `token_ref` coalesce to
one `token_env` slot.
`roles` carries the manifest route's role tuple (reserved for `roles` carries the manifest route's optional role markers (see
future use; always empty today). `manifest.EGRESS_ROLES`). The launch step reads these for
side effects like the claude-code OAuth placeholder env."""
`tls_passthrough` signals that pipelock must not TLS-MITM this
host either because the manifest declared `pipelock.tls_passthrough:
true` (lifted in `egress_manifest_routes`) or because a provider
route set it (e.g. egress injects its own Bearer on that host
after the agent boundary and pipelock's header DLP would block it)."""
host: str
path_allowlist: tuple[str, ...] = ()
auth_scheme: str = ""
token_env: str = ""
token_ref: str = "" token_ref: str = ""
roles: tuple[str, ...] = () roles: tuple[str, ...] = ()
tls_passthrough: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -132,62 +127,87 @@ 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, ...]:
"""Lift each `bottle.egress.routes[]` manifest entry into an EgressRoute. """Lift each `bottle.egress.routes[]` manifest entry into a
Order is preserved. Token slots are not assigned here slot assignment resolved EgressRoute. Order is preserved so route lookup at
is a final step in `egress_routes_for_bottle` after provider and manifest the proxy is stable.
routes are merged."""
Token-env slots are assigned per distinct `token_ref`: the first
authenticated route with `token_ref` "GH_PAT" gets
`EGRESS_TOKEN_0`; a second route with the same `token_ref`
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
no slot.
Does NOT include the folded-in DEFAULT_ALLOWLIST /
bottle.egress.allowlist bare-pass entries see
`egress_routes_for_bottle` for the effective set the
addon enforces."""
out: list[EgressRoute] = [] out: list[EgressRoute] = []
slot_for_token: dict[str, str] = {}
for r in bottle.egress.routes: for r in bottle.egress.routes:
out.append(EgressRoute( if r.AuthScheme and r.TokenRef:
host=r.Host, token_env = slot_for_token.get(r.TokenRef)
path_allowlist=r.PathAllowlist, if token_env is None:
auth_scheme=r.AuthScheme, token_env = f"EGRESS_TOKEN_{len(slot_for_token)}"
token_ref=r.TokenRef, slot_for_token[r.TokenRef] = token_env
roles=r.Role, out.append(EgressRoute(
tls_passthrough=r.Pipelock.TlsPassthrough, host=r.Host,
)) path_allowlist=r.PathAllowlist,
auth_scheme=r.AuthScheme,
token_env=token_env,
token_ref=r.TokenRef,
roles=r.Role,
))
else:
out.append(EgressRoute(
host=r.Host,
path_allowlist=r.PathAllowlist,
roles=r.Role,
))
return tuple(out) return tuple(out)
def egress_routes_for_bottle( def egress_routes_for_bottle(
bottle: Bottle, bottle: Bottle,
provider_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
"""Effective egress routes for the agent. """Effective egress routes: manifest routes followed by
bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what
gets rendered into routes.yaml + what the addon enforces.
Provider routes own their hosts outright; manifest routes for hosts Manifest routes win over defaults on host collision (manifest
not claimed by any provider are appended. Token slots are assigned routes carry more specific config auth, path filter, role
in a final pass over the merged list in order, so provisioned routes markers). Hostname comparison is case-insensitive.
get the lower slot numbers."""
manifest = egress_manifest_routes(bottle)
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
merged = list(provider_routes) + [
r for r in manifest if r.host.lower() not in provisioned_hosts
]
return _assign_token_slots(merged)
Operators that want to allow an arbitrary host that isn't in
def _assign_token_slots( DEFAULT_ALLOWLIST declare it directly in
routes: list[EgressRoute], `bottle.egress.routes` as a bare-pass entry
) -> tuple[EgressRoute, ...]: (`- host: <name>`). The legacy `bottle.egress.allowlist`
"""Assign EGRESS_TOKEN_N slots to authenticated routes in order. folding is gone egress is the single allowlist surface."""
out: list[EgressRoute] = list(egress_manifest_routes(bottle))
Routes sharing a token_ref share a slot. Unauthenticated routes claimed: set[str] = {r.host.lower() for r in out}
(no auth_scheme / token_ref) keep token_env empty.""" for host in DEFAULT_ALLOWLIST:
slot_for_ref: dict[str, str] = {} if host.lower() not in claimed:
out: list[EgressRoute] = [] out.append(EgressRoute(host=host))
for r in routes: claimed.add(host.lower())
if r.auth_scheme and r.token_ref:
slot = slot_for_ref.get(r.token_ref)
if slot is None:
slot = f"EGRESS_TOKEN_{len(slot_for_ref)}"
slot_for_ref[r.token_ref] = slot
out.append(dataclasses.replace(r, token_env=slot))
else:
out.append(r)
return tuple(out) return tuple(out)
@@ -203,7 +223,7 @@ def egress_token_env_map(
silently picking one.""" silently picking one."""
out: dict[str, str] = {} out: dict[str, str] = {}
for r in routes: for r in routes:
if not (r.auth_scheme and r.token_ref and r.token_env): if not r.token_env:
continue continue
existing = out.get(r.token_env) existing = out.get(r.token_env)
if existing is not None and existing != r.token_ref: if existing is not None and existing != r.token_ref:
@@ -216,43 +236,35 @@ def egress_token_env_map(
return out return out
def _route_to_yaml_fields(r: Route) -> dict:
"""Return the addon-visible fields for one route.
Single authoritative mapping between EgressRoute (host-side) and
egress_addon_core.Route (sidecar-side). When a field is added to
the addon's Route that must appear in the YAML, add it here and
in egress_addon_core._parse_one together."""
fields: dict = {"host": r.host}
if r.auth_scheme and r.token_env:
fields["auth_scheme"] = r.auth_scheme
fields["token_env"] = r.token_env
if r.path_allowlist:
fields["path_allowlist"] = list(r.path_allowlist)
return fields
def egress_render_routes( def egress_render_routes(
routes: tuple[EgressRoute, ...], routes: tuple[EgressRoute, ...],
) -> str: ) -> str:
"""Serialize the route table for the addon to read. """Serialize the route table for the addon to read.
YAML content no token values, no host env-var names. Fields are YAML content no token values, no host env-var names. The only
determined by `_route_to_yaml_fields`, which is the single point of thing the addon needs at runtime is the host path_allowlist
truth for the EgressRoute egress_addon_core.Route mapping.""" + auth_scheme + in-container env-var mapping. The actual token
values arrive via the container's environ.
Authenticated routes carry `auth_scheme` + `token_env`;
unauthenticated routes omit both keys (the addon's parser
enforces both-or-neither). Hand-rolled YAML in the style of
`pipelock_render_yaml` so the addon's parser
(`yaml_subset.parse_yaml_subset`) round-trips it cleanly."""
lines: list[str] = ["routes:"] lines: list[str] = ["routes:"]
if not routes: if not routes:
# `routes:` with an empty list on the same line — the parser
# needs SOMETHING here. Empty inline list is the cleanest.
lines[0] = "routes: []" lines[0] = "routes: []"
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
for r in routes: for r in routes:
f = _route_to_yaml_fields(r) lines.append(f' - host: "{r.host}"')
lines.append(f' - host: "{f["host"]}"') if r.auth_scheme and r.token_env:
if "auth_scheme" in f: lines.append(f' auth_scheme: "{r.auth_scheme}"')
lines.append(f' auth_scheme: "{f["auth_scheme"]}"') lines.append(f' token_env: "{r.token_env}"')
lines.append(f' token_env: "{f["token_env"]}"') if r.path_allowlist:
if "path_allowlist" in f:
lines.append(" path_allowlist:") lines.append(" path_allowlist:")
for p in f["path_allowlist"]: for p in r.path_allowlist:
lines.append(f' - "{p}"') lines.append(f' - "{p}"')
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@@ -292,23 +304,18 @@ class Egress(ABC):
sidecar's start/stop lifecycle is backend-specific and lives on sidecar's start/stop lifecycle is backend-specific and lives on
concrete subclasses.""" concrete subclasses."""
def prepare( def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> EgressPlan:
self, """Lift `bottle.egress.routes` into resolved routes,
bottle: Bottle, render the routes file (mode 600) under `stage_dir`, and
slug: str,
stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (),
) -> EgressPlan:
"""Lift `bottle.egress.routes` + `provider_routes` into resolved
routes, render the routes file (mode 600) under `stage_dir`, and
return the plan. Pure host-side, no docker subprocess. The return the plan. Pure host-side, no docker subprocess. The
token-env map records the mapping the launch step uses to token-env map records the mapping the launch step uses to
forward values from the host's environ into the sidecar's environ. forward values from the host's environ into the sidecar's
environ.
Returned plan is incomplete: the launch step must fill Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` / `pipelock_proxy_url` `internal_network` / `egress_network` / `pipelock_proxy_url`
via `dataclasses.replace` before passing it to `.start`.""" via `dataclasses.replace` before passing it to `.start`."""
routes = egress_routes_for_bottle(bottle, provider_routes) routes = egress_routes_for_bottle(bottle)
routes_path = stage_dir / "egress_routes.yaml" routes_path = stage_dir / "egress_routes.yaml"
routes_path.write_text(egress_render_routes(routes)) routes_path.write_text(egress_render_routes(routes))
routes_path.chmod(0o600) routes_path.chmod(0o600)
@@ -320,7 +327,7 @@ class Egress(ABC):
) )
__all__ = [ __all__ = [
"CODEX_HOST_CREDENTIAL_TOKEN_REF", "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 (bot_bottle/) is the parallel file in the package source tree (claude_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 `bot_bottle` package. The try/except # import resolves under the `claude_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 bot_bottle/sidecar_init.py can # ENTRYPOINT so the supervisor in claude_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
# route-configured pipelock passthrough hosts. # pipelock-passthrough hosts (api.anthropic.com etc.).
# * `-s /app/egress_addon.py` loads the addon that reads # * `-s /app/egress_addon.py` loads the addon that reads
# /etc/egress/routes.yaml. # /etc/egress/routes.yaml.
+2 -2
View File
@@ -98,7 +98,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
prompt = ( prompt = (
f"{prompt_body} (input hidden): " f"{prompt_body} (input hidden): "
if prompt_body if prompt_body
else f"bot-bottle: secret value for {name} (input hidden): " else f"claude-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"bot-bottle: secret value for {name} (input hidden): " else f"claude-bottle: secret value for {name} (input hidden): "
) )
value = getpass.getpass(prompt) value = getpass.getpass(prompt)
if not value: if not value:
@@ -25,25 +25,26 @@ 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
`bot_bottle/backend/docker/git_gate.py`).""" `claude_bottle/backend/docker/git_gate.py`)."""
from __future__ import annotations from __future__ import annotations
import shlex
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Mapping
from .log import die
from .manifest import Bottle, GitEntry from .manifest import Bottle, GitEntry
# Short network alias for git-gate inside the sidecar bundle. The # Short network alias for git-gate inside the sidecar bundle. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name. # agent's `.gitconfig` insteadOf rewrites resolve through this name.
GIT_GATE_HOSTNAME = "git-gate" GIT_GATE_HOSTNAME = "git-gate"
# Bound half-open git client sessions. If an agent/tool runner is
# interrupted during push, git daemon should reap the receive-pack
# child instead of keeping the gate wedged indefinitely. def _empty_str_map() -> dict[str, str]:
GIT_GATE_DAEMON_TIMEOUT_SECS = 15 return {}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -59,7 +60,10 @@ class GitGateUpstream:
KnownHostKey string from the manifest; the gate's start step KnownHostKey string from the manifest; the gate's start step
materialises it into a known_hosts file if non-empty. materialises it into a known_hosts file if non-empty.
the gate credential paths inside the running sidecar.""" `extra_hosts` is a `{hostname: ip}` map the backend injects into
the gate container's `/etc/hosts` via `--add-host` so the gate
can resolve upstream hostnames that aren't reachable via the
container's default DNS (e.g. Tailscale-only hosts)."""
name: str name: str
upstream_url: str upstream_url: str
@@ -67,7 +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)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -104,19 +108,46 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
upstream_port=e.UpstreamPort, upstream_port=e.UpstreamPort,
identity_file=e.IdentityFile, identity_file=e.IdentityFile,
known_host_key=e.KnownHostKey, known_host_key=e.KnownHostKey,
extra_hosts=dict(e.ExtraHosts),
) )
for e in bottle.git for e in bottle.git
) )
def git_gate_aggregate_extra_hosts(
upstreams: tuple[GitGateUpstream, ...],
) -> dict[str, str]:
"""Merge every upstream's `extra_hosts` into a single
`{hostname: ip}` map for `--add-host` on the gate container. Two
entries naming the same hostname with different IPs is a manifest
bug the gate has one /etc/hosts so die loudly with the
conflicting names rather than silently picking one."""
merged: dict[str, str] = {}
source: dict[str, str] = {}
for u in upstreams:
for host, ip in u.extra_hosts.items():
existing = merged.get(host)
if existing is None:
merged[host] = ip
source[host] = u.name
elif existing != ip:
die(
f"git-gate ExtraHosts conflict: '{host}' maps to "
f"'{existing}' in upstream '{source[host]}' and to "
f"'{ip}' in upstream '{u.name}'. The gate has one "
f"/etc/hosts; pick one IP."
)
return merged
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[GitEntry, ...], gate_host: str
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / smolvm; `insteadOf` rewrites. Pure host-side, no docker / smolvm;
exposed for tests + reuse across backends. exposed for tests + reuse across backends.
`gate_host` is the part of the URL between `<scheme>://` and the `gate_host` is the part of the URL between `git://` and the
repo path backends differ here: repo path backends differ here:
- docker: `git-gate` (the short network alias) - docker: `git-gate` (the short network alias)
- smolmachines: `<bundle_ip>:<port>` (no DNS in the - smolmachines: `<bundle_ip>:<port>` (no DNS in the
@@ -127,25 +158,14 @@ def git_gate_render_gitconfig(
if not entries: if not entries:
return "" return ""
out = [ out = [
"# bot-bottle git-gate (PRD 0008): every git operation against\n", "# claude-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",
] ]
for entry in entries: for entry in entries:
out.append(f'[url "{scheme}://{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)
@@ -201,20 +221,20 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
" git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"", " git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"",
" git -C \"$repo\" config receive.denyCurrentBranch ignore", " git -C \"$repo\" config receive.denyCurrentBranch ignore",
" git -C \"$repo\" config http.receivepack true",
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
"}", "}",
"", "",
"mkdir -p /git", "mkdir -p /git",
] ]
for u in upstreams: for u in upstreams:
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}") # Single-quote args so URL/path content (containing : and /)
# passes through ash unmangled. Names came through the manifest
# validator so they don't contain a single quote.
lines.append(f"init_repo '{u.name}' '{u.upstream_url}'")
lines.extend([ lines.extend([
"", "",
"exec git daemon \\", "exec git daemon \\",
" --reuseaddr \\", " --reuseaddr \\",
f" --timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
f" --init-timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
" --base-path=/git \\", " --base-path=/git \\",
" --export-all \\", " --export-all \\",
" --enable=receive-pack \\", " --enable=receive-pack \\",
@@ -248,14 +268,7 @@ while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue [ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue [ "$new" = "$zero" ] && continue
if [ "$old" = "$zero" ]; then if [ "$old" = "$zero" ]; then
# New ref: scan only the commits this push introduces — those log_opts="$new"
# reachable from $new but not from any ref the gate already has.
# Everything already on the gate arrived via upstream mirror-fetch
# or a previously gitleaks-scanned push, so it's already-upstream
# or already-scanned; re-scanning it (the old `$new` full-ancestry
# range) only resurfaces historical findings and blocks every new
# branch. See PRD 0028 / issue #106.
log_opts="$new --not --all"
else else
log_opts="$old..$new" log_opts="$old..$new"
fi fi
@@ -275,7 +288,7 @@ if [ ! -f "$hostsfile" ]; then
echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2 echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2
exit 1 exit 1
fi fi
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10" ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
while IFS=' ' read -r old new ref; do while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue [ -z "$ref" ] && continue
@@ -330,7 +343,7 @@ if [ -z "$keyfile" ] || [ ! -f "$hostsfile" ]; then
echo "git-gate: missing credentials for $repo_dir; refusing fetch" >&2 echo "git-gate: missing credentials for $repo_dir; refusing fetch" >&2
exit 1 exit 1
fi fi
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10" ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
echo "git-gate: refreshing $repo_dir from upstream" >&2 echo "git-gate: refreshing $repo_dir from upstream" >&2
if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; then if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; then
@@ -384,32 +397,11 @@ 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,
)
)
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=tuple(upstreams_with_files), upstreams=upstreams,
) )
+24
View File
@@ -0,0 +1,24 @@
"""Tiny logging wrappers. All output goes to stderr."""
from __future__ import annotations
import sys
from typing import NoReturn
def info(msg: str) -> None:
print(f"claude-bottle: {msg}", file=sys.stderr)
def warn(msg: str) -> None:
print(f"claude-bottle: warning: {msg}", file=sys.stderr)
class Die(SystemExit):
"""Raised by die() so callers (and tests) can distinguish a deliberate
fatal exit from an unrelated SystemExit."""
def die(msg: str) -> NoReturn:
print(f"claude-bottle: error: {msg}", file=sys.stderr)
raise Die(1)

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