Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff1730588 | |||
| ad048ae75d | |||
| df559e43be |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# bot-bottle container image.
|
||||
# claude-bottle container image.
|
||||
#
|
||||
# Goal: a small, cache-friendly base that ships claude-code (the
|
||||
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
|
||||
@@ -17,13 +17,13 @@ FROM node:22-slim
|
||||
# image, those features fail in surprising ways once the user does any
|
||||
# real work. ca-certificates is already in the slim base; listed for
|
||||
# clarity in case the base ever drops it. socat is the privileged
|
||||
# forwarder for the in-container ssh-agent (see 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
|
||||
# node and the agent socket. curl is here so any HTTPS_PROXY-aware
|
||||
# tool (curl itself, plus anything that shells out to it) works
|
||||
# against pipelock's bumped TLS without the agent needing local DNS.
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install claude-code globally. Pinned to the version verified in the v1
|
||||
@@ -40,7 +40,7 @@ USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
# 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
|
||||
# baking it into the image avoids a permission-confusion footgun if a
|
||||
# future change to the launcher copies in as a different user.
|
||||
@@ -60,7 +60,7 @@ RUN cat > "$HOME/.claude.json" <<JSON
|
||||
JSON
|
||||
|
||||
# Default to an interactive claude session. In the v1 launcher,
|
||||
# `bot_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
||||
# to attach a TTY, but this CMD makes `docker run -it bot-bottle-claude` also
|
||||
# `claude_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
||||
# to attach a TTY, but this CMD makes `docker run -it claude-bottle` also
|
||||
# do something useful for ad-hoc debugging.
|
||||
CMD ["claude"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Per-bottle egress sidecar image (PRD 0017).
|
||||
#
|
||||
# Replaces cred-proxy (PRD 0010). Sits on the agent's HTTP_PROXY /
|
||||
# HTTPS_PROXY path (wiring lands in chunk 2) and owns three jobs:
|
||||
# 1. MITM HTTPS using the per-bottle CA (chunk 2 moves the CA
|
||||
# generation from pipelock).
|
||||
# 2. Enforce manifest-declared path_allowlist per route.
|
||||
# 3. Inject Authorization headers for routes that declare an auth
|
||||
# block.
|
||||
#
|
||||
# Chunk 1 of PRD 0017 ships this image and the addon. Wiring it
|
||||
# into the bottle launch (and the per-bottle CA + the pipelock
|
||||
# upstream proxy) is chunk 2.
|
||||
|
||||
# mitmproxy base image. mitmdump + addon API are already there; we
|
||||
# only need to drop our addon in. TODO: pin by digest.
|
||||
FROM mitmproxy/mitmproxy:11.1.3
|
||||
|
||||
USER root
|
||||
|
||||
# The addon ships as three files. `_core.py` is pure-logic,
|
||||
# importable both inside the container and from the host's tests;
|
||||
# `_addon.py` is the mitmproxy hook wrapper; `yaml_subset.py` is
|
||||
# the stdlib-only YAML parser the addon uses to read routes.yaml.
|
||||
# All three land flat in /app/ so mitmdump's loader resolves them
|
||||
# as top-level sibling modules (absolute imports).
|
||||
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||
COPY claude_bottle/egress_addon.py /app/egress_addon.py
|
||||
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
|
||||
# Pre-create the runtime directories the backend's start step will
|
||||
# `docker cp` into. docker cp does not create intermediate dirs, so
|
||||
# the mkdir must be baked into the image.
|
||||
# /etc/egress routes.yaml lands here
|
||||
# ~/.mitmproxy mitmproxy CA (cert+key concat) + the
|
||||
# pipelock CA (cert only, for upstream
|
||||
# trust on the HTTPS_PROXY=pipelock leg)
|
||||
# Ownership lets the unprivileged mitmproxy user read the files.
|
||||
RUN mkdir -p /etc/egress /home/mitmproxy/.mitmproxy \
|
||||
&& chown -R mitmproxy:mitmproxy /etc/egress /home/mitmproxy/.mitmproxy /app
|
||||
|
||||
USER mitmproxy
|
||||
|
||||
# Listening port. Agents dial egress on this port via their
|
||||
# HTTP_PROXY env. Surfaced as EXPOSE for documentation; not required
|
||||
# for the internal network to route to it.
|
||||
EXPOSE 9099
|
||||
|
||||
# Entrypoint:
|
||||
# - Upstream proxy: when EGRESS_UPSTREAM_PROXY is set,
|
||||
# use mitmproxy's `--mode upstream:URL` to forward all
|
||||
# post-MITM traffic through pipelock. (mitmproxy does NOT
|
||||
# honor HTTPS_PROXY env vars on its outbound side — it's a
|
||||
# proxy server, not a client.) Standalone runs without
|
||||
# EGRESS_UPSTREAM_PROXY fall back to `regular@9099`
|
||||
# direct-to-upstream — useful for unit tests of the image.
|
||||
# - Upstream trust: when EGRESS_UPSTREAM_CA is set, build
|
||||
# a combined trust bundle (system roots + pipelock CA) and
|
||||
# point mitmproxy at it via
|
||||
# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES
|
||||
# mitmproxy's default trust store with the file we point it
|
||||
# at — passing just pipelock's CA would break pipelock-
|
||||
# passthrough hosts (api.anthropic.com etc.) where mitmproxy
|
||||
# sees real upstream certs signed by public CAs. The combined
|
||||
# bundle covers both pipelock-MITM'd and pipelock-passthrough
|
||||
# hosts.
|
||||
# - -s /app/egress_addon.py → loads our addon, reads
|
||||
# /etc/egress/routes.yaml.
|
||||
ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_UPSTREAM_CA\" ] && [ -f \"$EGRESS_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py"]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Per-agent git-gate sidecar image (PRD 0008).
|
||||
#
|
||||
# Runs `git daemon --enable=receive-pack` so the agent in the bottle
|
||||
# can push to it over git://. A shared pre-receive hook runs gitleaks
|
||||
# against each incoming ref; on clean, it forwards the ref to the real
|
||||
# upstream using a credential the gate holds. The agent never sees the
|
||||
# upstream credential.
|
||||
#
|
||||
# The agent-facing leg sits on a Docker --internal network with no
|
||||
# default route, so the image is fully self-contained: no apk pulls at
|
||||
# boot, no remote registry lookups during the entrypoint.
|
||||
|
||||
# Base on the upstream gitleaks image (alpine + gitleaks v8.x);
|
||||
# alpine doesn't package gitleaks so this avoids a separate
|
||||
# install path. Pinned by digest for reproducibility.
|
||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
|
||||
|
||||
# openssh-client supplies the upstream SSH transport the pre-receive
|
||||
# hook uses to forward accepted refs. git-daemon is the listener the
|
||||
# agent pushes to (alpine ships `git-daemon` as a sub-package, not
|
||||
# part of `git`). The `git` core binary is already in the base image.
|
||||
RUN apk add --no-cache openssh-client git-daemon
|
||||
|
||||
# Layout the gate uses at runtime:
|
||||
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
||||
# /etc/git-gate/pre-receive — shared hook, docker-cp'd at start
|
||||
# /git-gate/creds/<name>-key — per-upstream identity, docker-cp'd
|
||||
# /git-gate/creds/<name>-known_hosts — per-upstream known_hosts, docker-cp'd
|
||||
# /git/<name>.git — bare repos, created by the entrypoint
|
||||
#
|
||||
# The intermediate directories must exist before `docker cp` runs (cp
|
||||
# does not create them); the bare-repo parent (/git) is also pre-created
|
||||
# defensively.
|
||||
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
||||
|
||||
# Base image's ENTRYPOINT is the gitleaks binary; override explicitly.
|
||||
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
||||
@@ -1,110 +0,0 @@
|
||||
# Per-bottle sidecar bundle image (PRD 0024).
|
||||
#
|
||||
# Collapses the four prior per-sidecar images (pipelock, egress,
|
||||
# git-gate, supervise) into one. A small stdlib-Python init
|
||||
# supervisor at /app/sidecar_init.py spawns all four daemons,
|
||||
# forwards SIGTERM, and propagates per-daemon stdout/stderr to the
|
||||
# container log with a `[name]` prefix. See PRD 0024 for the
|
||||
# rationale.
|
||||
#
|
||||
# Layout (preserved verbatim from the prior four Dockerfiles so the
|
||||
# compose renderer's bind-mount paths and docker-cp targets keep
|
||||
# working):
|
||||
#
|
||||
# /usr/local/bin/pipelock pipelock binary
|
||||
# /usr/bin/gitleaks gitleaks binary
|
||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||
# /app/egress-entrypoint.sh mitmdump launcher
|
||||
# /app/supervise_server.py + .py supervise MCP server
|
||||
# /app/sidecar_init.py PID 1 supervisor
|
||||
# /etc/pipelock.yaml bind-mounted at run time
|
||||
# /etc/egress/routes.yaml bind-mounted at run time
|
||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||
# /git-gate/creds/* docker-cp'd at start time
|
||||
# /git/* bare repos, populated at runtime
|
||||
# /run/supervise/queue/ bind-mounted at run time
|
||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||
#
|
||||
# Exposed ports inside the container:
|
||||
# 8888 pipelock (HTTPS_PROXY)
|
||||
# 9099 egress (mitmproxy, pipelock's upstream — not externally
|
||||
# addressed by the agent)
|
||||
# 9418 git-gate (git-daemon)
|
||||
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
||||
# 9100 supervise (MCP HTTP)
|
||||
|
||||
# Stage 1: pipelock binary. The upstream pipelock image is a
|
||||
# scratch image with the binary at /pipelock (entrypoint).
|
||||
# Pinned by digest in lockstep with
|
||||
# bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE.
|
||||
FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src
|
||||
|
||||
# Stage 2: gitleaks binary. The upstream gitleaks image is alpine
|
||||
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
|
||||
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
|
||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
|
||||
|
||||
# Stage 3: assembly. mitmproxy/mitmproxy is debian-slim-based with
|
||||
# Python + mitmdump pre-installed — heavier than the others, so
|
||||
# this stage starts there and pulls the standalone binaries in.
|
||||
FROM mitmproxy/mitmproxy:11.1.3
|
||||
|
||||
# Run as root inside the bundle. The bundle is the isolation
|
||||
# boundary; per-daemon user separation inside it is not load-bearing
|
||||
# and complicates the supervisor's spawn path.
|
||||
USER root
|
||||
|
||||
# Runtime system deps:
|
||||
# git supplies the `git daemon` subcommand (no separate package)
|
||||
# plus the core `git` binary the pre-receive hook invokes.
|
||||
# openssh-client supplies the upstream SSH transport the
|
||||
# pre-receive hook uses to forward accepted refs.
|
||||
# ca-certificates is needed for both pipelock and mitmdump
|
||||
# upstream TLS (the base image already has it; listed for
|
||||
# explicitness).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git openssh-client ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pull the standalone binaries into the final image.
|
||||
COPY --from=pipelock-src /pipelock /usr/local/bin/pipelock
|
||||
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
||||
|
||||
# Project Python: addon + server modules + the init supervisor.
|
||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||
# top-level siblings (absolute imports), matching the prior
|
||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
RUN chmod +x /app/egress-entrypoint.sh
|
||||
|
||||
# Pre-create runtime directories the compose renderer + start
|
||||
# step expect to exist. `docker cp` does not create intermediate
|
||||
# dirs, and bind mounts won't either if the parent is missing.
|
||||
RUN mkdir -p \
|
||||
/etc/egress \
|
||||
/etc/git-gate \
|
||||
/git-gate/creds \
|
||||
/git \
|
||||
/run/supervise/queue \
|
||||
/home/mitmproxy/.mitmproxy
|
||||
|
||||
# Documentation only — the compose renderer publishes whichever
|
||||
# subset the bottle uses.
|
||||
EXPOSE 8888 9099 9418 9420 9100
|
||||
|
||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
||||
WORKDIR /app
|
||||
|
||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||
# propagation; no `exec` chain in the entrypoint itself.
|
||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Per-bottle supervise sidecar image (PRD 0013).
|
||||
#
|
||||
# Exposes three MCP tools (cred-proxy-block, pipelock-block,
|
||||
# capability-block) the agent calls to propose config changes when
|
||||
# stuck. Each tool call writes a Proposal to a host-mounted queue
|
||||
# dir and blocks waiting for the operator's Response.
|
||||
#
|
||||
# Stdlib-only Python. The bottle slug arrives via
|
||||
# SUPERVISE_BOTTLE_SLUG; the host's ~/.claude-bottle/queue/<slug>/
|
||||
# is bind-mounted at /run/supervise/queue.
|
||||
|
||||
# python:3.13-alpine, pinned by digest (same image cred-proxy uses,
|
||||
# so docker pulls / caches once for both sidecars).
|
||||
FROM python@sha256:420cd0bf0f3998275875e02ecd5808168cf0843cbb4d3c536432f729247b2acc
|
||||
|
||||
# Both files ship as single files into /app; supervise_server.py
|
||||
# imports supervise via same-directory resolution.
|
||||
COPY claude_bottle/supervise.py /app/supervise.py
|
||||
COPY claude_bottle/supervise_server.py /app/supervise_server.py
|
||||
|
||||
# Pre-create the queue mount point so docker's bind-mount has a
|
||||
# parent dir. Matches Dockerfile.cred-proxy's pattern.
|
||||
RUN mkdir -p /run/supervise/queue
|
||||
|
||||
EXPOSE 9100
|
||||
|
||||
# WORKDIR makes the in-app same-dir import deterministic regardless
|
||||
# of how the container is launched.
|
||||
WORKDIR /app
|
||||
|
||||
# PID 1 is python for clean signal handling and exit codes.
|
||||
ENTRYPOINT ["python3", "/app/supervise_server.py"]
|
||||
@@ -1,10 +1,10 @@
|
||||
<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>
|
||||
|
||||
# bot-bottle
|
||||
# claude-bottle
|
||||
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||
[](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.
|
||||
|
||||
@@ -21,7 +21,7 @@ asked to commit and push an AKIA-shaped key, git-gate's gitleaks
|
||||
pre-receive hook rejects the ref.
|
||||
Run it yourself with `bash scripts/demo.sh`.
|
||||
|
||||
## Why "bot-bottle"?
|
||||
## Why "claude-bottle"?
|
||||
|
||||
Each container is a bottle; Claude is the genie inside. The genie's
|
||||
powers are exactly what the manifest grants it — a specific set of
|
||||
@@ -37,17 +37,6 @@ the genie does not persist.
|
||||
- Run multiple agents in parallel, isolated from each other
|
||||
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
|
||||
|
||||
## Project status
|
||||
|
||||
bot-bottle is a self-hosted secure runtime for AI coding agents.
|
||||
Each agent runs in an isolated container or micro-VM-backed bottle with
|
||||
scoped secrets, allowlisted egress, TLS-aware proxying, DLP checks, and
|
||||
a git-gate that withholds upstream credentials and scans pushes before
|
||||
forwarding. The project includes a documented threat model, PRD-driven
|
||||
development history, Docker and smolmachines backends, dashboard and
|
||||
remediation flows, and unit/integration tests covering exfiltration and
|
||||
sandbox escape scenarios.
|
||||
|
||||
## Security model
|
||||
|
||||
Each agent runs in its own bottle: its own container, its own internal
|
||||
@@ -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
|
||||
minimization and egress allowlisting than on the container as a
|
||||
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
|
||||
manifest configuration required. The broader v2 discussion lives in
|
||||
`docs/research/stronger-isolation-alternatives.md`.
|
||||
@@ -80,22 +69,16 @@ pieces of v1.
|
||||
|
||||
## Architecture
|
||||
|
||||
A bottle is two containers per agent: an `agent` container, and a
|
||||
`sidecars` container that bundles pipelock + egress + git-gate +
|
||||
supervise behind a Python init supervisor (PRD 0024). They share a
|
||||
per-agent Docker `--internal` network; the agent has no default
|
||||
route off-box. All HTTP and HTTPS egress funnels through pipelock,
|
||||
where the egress allowlist, TLS interception, and request-body DLP
|
||||
scanner enforce the manifest before any byte leaves the host. The
|
||||
only egress that doesn't traverse pipelock is git-gate's SSH
|
||||
push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
|
||||
so git-gate is its own L4-style egress path with gitleaks doing
|
||||
the pre-receive scan.
|
||||
|
||||
The agent dials the bundle by the legacy short names (`pipelock`,
|
||||
`egress`, `git-gate`, `supervise`); the renderer registers those as
|
||||
docker-network aliases on the bundle so existing HTTPS_PROXY URLs
|
||||
and MCP endpoints resolve without an agent-side change.
|
||||
A bottle is the agent container plus up to three per-protocol egress
|
||||
sidecars on a per-agent Docker `--internal` network. The agent has no
|
||||
default route off-box. All HTTP and HTTPS egress — from the agent
|
||||
*and* from cred-proxy when it dials an upstream — funnels through
|
||||
pipelock, where the egress allowlist, TLS interception, and
|
||||
request-body DLP scanner enforce the manifest before any byte leaves
|
||||
the host. The only egress that doesn't traverse pipelock is git-gate's
|
||||
SSH push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
|
||||
so git-gate is its own L4-style egress path with gitleaks doing the
|
||||
pre-receive scan.
|
||||
|
||||
```
|
||||
host ( ./cli.py )
|
||||
@@ -137,12 +120,10 @@ and MCP endpoints resolve without an agent-side change.
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **agent image** — built from the provider template Dockerfile
|
||||
(`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or
|
||||
`agent_provider.dockerfile`) on first run; runs the selected agent
|
||||
CLI with the manifest-granted skills, env vars, and `~/.gitconfig`
|
||||
(the latter for the git-gate's `insteadOf` rules when `bottle.git`
|
||||
is set).
|
||||
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
||||
base) on first run; runs `claude` with the manifest-granted skills,
|
||||
env vars, and `~/.gitconfig` (the latter for the git-gate's
|
||||
`insteadOf` rules when `bottle.git` is set).
|
||||
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
||||
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
||||
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
||||
@@ -157,8 +138,14 @@ and MCP endpoints resolve without an agent-side change.
|
||||
upstream has *now* (fail-closed if unreachable). The agent's
|
||||
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
|
||||
so push, fetch, clone, and pull all route through. The agent
|
||||
never sees the upstream credential. Brought up only when
|
||||
`bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`.
|
||||
never sees the upstream credential. If the upstream's hostname
|
||||
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`
|
||||
base, stdlib-only) that holds API tokens declared in
|
||||
`bottle.cred_proxy.routes`. Each route names a `path`,
|
||||
@@ -197,44 +184,14 @@ The container is removed automatically when the session ends. If the script
|
||||
is killed with SIGKILL the exit trap won't fire and the container may be
|
||||
left running; remove it with `docker rm -f <container-name>`.
|
||||
|
||||
### Smolmachines backend (experimental, macOS-only)
|
||||
|
||||
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
|
||||
sidecar bundle still in Docker. Selected via
|
||||
`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
|
||||
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
|
||||
|
||||
The integration tests run against whichever backend the env var
|
||||
selects and skip cleanly when its prerequisites are missing.
|
||||
|
||||
**One-time sudo on first launch (macOS):** smolmachines bottles
|
||||
each reserve a loopback alias from a pool (`127.0.0.16` ..
|
||||
`127.0.0.31`) and bind their bundle's port-forwards to it; the
|
||||
first `./cli.py start` after each reboot prompts for sudo to add
|
||||
missing aliases via `ifconfig lo0 alias`. Aliases persist until
|
||||
reboot; subsequent launches don't prompt. The agent's TSI
|
||||
allowlist is the alias's `/32`, so each bottle can only reach
|
||||
its own bundle's published ports — not other bottles' ports,
|
||||
not other host loopback services (postgres, dev servers, etc.).
|
||||
|
||||
This enforcement requires a workaround for a smolvm 0.8.0 bug:
|
||||
the CLI's `--allow-cidr` flag is silently dropped when combined
|
||||
with `--from <smolmachine>`. The launcher patches smolvm's
|
||||
persistent state DB
|
||||
(`~/Library/Application Support/smolvm/server/smolvm.db`)
|
||||
directly between `machine create` and `machine start` to set
|
||||
the allowlist. The hack falls away automatically when smolvm
|
||||
honors the flag upstream — see the `loopback_alias` module's
|
||||
docstring for the investigation trail.
|
||||
|
||||
## Manifest
|
||||
|
||||
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/`:
|
||||
|
||||
```
|
||||
~/.bot-bottle/
|
||||
~/.claude-bottle/
|
||||
├── bottles/
|
||||
│ ├── dev.md
|
||||
│ └── gitea-dev.md
|
||||
@@ -247,148 +204,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.
|
||||
|
||||
A repo can ship its own agent files alongside its code at
|
||||
`<repo>/.bot-bottle/agents/<name>.md`. Those agents reference
|
||||
bottles defined in `~/.bot-bottle/bottles/` (the only place
|
||||
`<repo>/.claude-bottle/agents/<name>.md`. Those agents reference
|
||||
bottles defined in `~/.claude-bottle/bottles/` (the only place
|
||||
bottles can come from); a `bottles/` subdir in a repo is ignored
|
||||
with a warning. **This is the trust boundary**: bottle infrastructure
|
||||
— credentials, egress allowlists, git remotes — comes from your home
|
||||
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.
|
||||
|
||||
### Bottle composition with `extends:`
|
||||
|
||||
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:
|
||||
### Example bottle (`~/.claude-bottle/bottles/gitea-dev.md`)
|
||||
|
||||
````markdown
|
||||
---
|
||||
agent_provider:
|
||||
template: claude
|
||||
|
||||
egress:
|
||||
routes:
|
||||
- host: api.anthropic.com
|
||||
role: claude_code_oauth
|
||||
auth:
|
||||
scheme: Bearer
|
||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||
pipelock:
|
||||
tls_passthrough: true
|
||||
---
|
||||
|
||||
Common Claude provider boundary.
|
||||
````
|
||||
|
||||
Task bottles can then inherit that provider boundary and add their own
|
||||
env/git configuration without repeating the Claude route.
|
||||
|
||||
### Example bottle (`~/.bot-bottle/bottles/gitea-dev.md`)
|
||||
|
||||
````markdown
|
||||
---
|
||||
extends: claude
|
||||
|
||||
env:
|
||||
GIT_AUTHOR_NAME: didericis
|
||||
|
||||
git:
|
||||
user:
|
||||
name: "Eric Bauerfeld"
|
||||
email: "eric+claude@dideric.is"
|
||||
remotes:
|
||||
gitea.dideric.is:
|
||||
Name: bot-bottle
|
||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||
KnownHostKey: ssh-ed25519 AAAA...
|
||||
- Name: claude-bottle
|
||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
|
||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||
KnownHostKey: ssh-ed25519 AAAA...
|
||||
|
||||
# Routes declared here are held by a per-bottle cred-proxy sidecar,
|
||||
# not the agent. Each route names a path the agent dials, the
|
||||
# upstream the proxy forwards to, an auth_scheme, and a token_ref
|
||||
# (host env var). The value goes into the sidecar's environ via
|
||||
# `docker create -e`, never touches argv or disk. Optional `role`
|
||||
# tags drive agent-side rewrites: anthropic-base-url (sets
|
||||
# ANTHROPIC_BASE_URL), npm-registry (writes ~/.npmrc), git-insteadof
|
||||
# (writes ~/.gitconfig), tea-login (writes ~/.config/tea/config.yml).
|
||||
# See docs/prds/0010-cred-proxy.md.
|
||||
cred_proxy:
|
||||
routes:
|
||||
- path: /anthropic/
|
||||
upstream: https://api.anthropic.com
|
||||
auth_scheme: Bearer
|
||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
||||
role: anthropic-base-url
|
||||
- path: /gh-api/
|
||||
upstream: https://api.github.com
|
||||
auth_scheme: Bearer
|
||||
token_ref: GH_PAT
|
||||
- path: /gh-git/
|
||||
upstream: https://github.com
|
||||
auth_scheme: Bearer
|
||||
token_ref: GH_PAT
|
||||
role: git-insteadof
|
||||
- path: /npm/
|
||||
upstream: https://registry.npmjs.org
|
||||
auth_scheme: Bearer
|
||||
token_ref: NPM_TOKEN
|
||||
role: npm-registry
|
||||
|
||||
# Egress is forced through a per-agent pipelock sidecar on a Docker
|
||||
# `--internal` network — without the proxy the agent has no route
|
||||
# off-box. The effective allowlist is the union of baked-in defaults
|
||||
# (api.anthropic.com, claude.ai, ...) and the hostnames listed here.
|
||||
# Pipelock also runs DLP scanning and detects URL-embedded
|
||||
# high-entropy secrets. The resolved allowlist is shown in the y/N
|
||||
# preflight before launch.
|
||||
egress:
|
||||
allowlist:
|
||||
- github.com
|
||||
- registry.npmjs.org
|
||||
- pypi.org
|
||||
---
|
||||
|
||||
The `gitea-dev` bottle. Backs my work on personal projects: provider
|
||||
auth through egress and gitea.dideric.is over SSH.
|
||||
The `gitea-dev` bottle. Backs my work on personal projects: Anthropic
|
||||
OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea
|
||||
API), and npm for publishing scoped packages.
|
||||
````
|
||||
|
||||
For a Codex-backed base bottle, set `agent_provider.template: codex`.
|
||||
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`)
|
||||
### Example agent (`~/.claude-bottle/agents/gitea-helper.md`)
|
||||
|
||||
````markdown
|
||||
---
|
||||
bottle: gitea-dev
|
||||
skills:
|
||||
- init-prd
|
||||
git:
|
||||
user:
|
||||
name: gitea-helper
|
||||
email: eric+gitea-helper@dideric.is
|
||||
---
|
||||
|
||||
You help maintain Gitea-hosted projects.
|
||||
@@ -398,23 +291,10 @@ The agent's Markdown body is its system prompt (whitespace
|
||||
stripped). The frontmatter declares the bottle to launch in and any
|
||||
skills to mount. You can also include Claude Code subagent fields
|
||||
(`name`, `description`, `model`, `color`, `memory`) in the
|
||||
frontmatter — 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
|
||||
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"
|
||||
pointer; typos don't silently ghost into an empty config.
|
||||
|
||||
@@ -424,26 +304,25 @@ nested dicts). Anchors, multi-line block scalars, tags, and
|
||||
ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
|
||||
`0x...`) all die with a clear pointer at the spec — quote your
|
||||
strings when in doubt. The full schema lives in
|
||||
`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
|
||||
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
|
||||
rationale in `docs/research/pipelock-assessment.md`. The trust
|
||||
boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
|
||||
|
||||
## Auth: Claude OAuth token, not API key
|
||||
## Auth: OAuth token, not API key
|
||||
|
||||
Bottles that use `agent_provider.template: claude` authenticate
|
||||
`claude` inside the container with the same Pro/Max subscription you
|
||||
already use on the host, via a long-lived OAuth token. No
|
||||
`ANTHROPIC_API_KEY` is needed.
|
||||
claude-bottle authenticates `claude` inside the container with the same
|
||||
Pro/Max subscription you already use on the host, via a long-lived OAuth
|
||||
token. No `ANTHROPIC_API_KEY` is needed.
|
||||
|
||||
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
|
||||
Code stores OAuth credentials in the encrypted Keychain, not in
|
||||
`~/.claude.json`. Mounting that file into a Linux container does not
|
||||
carry the credentials with it. Linux hosts keep credentials in
|
||||
`~/.claude/.credentials.json`, but to keep the launcher portable
|
||||
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:**
|
||||
|
||||
@@ -452,45 +331,28 @@ claude setup-token # browser login, prints a ~1-year OAuth token
|
||||
```
|
||||
|
||||
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
|
||||
as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
|
||||
as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
|
||||
|
||||
```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
|
||||
sidecar. To let `claude` authenticate, declare an egress route with
|
||||
`role: claude_code_oauth` and
|
||||
`token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
|
||||
The bottle reaches the Anthropic API only through the cred-proxy
|
||||
sidecar. To let `claude` authenticate, declare a route in
|
||||
`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
|
||||
`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
|
||||
|
||||
```yaml
|
||||
egress:
|
||||
routes:
|
||||
- host: api.anthropic.com
|
||||
role: claude_code_oauth
|
||||
auth:
|
||||
scheme: Bearer
|
||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||
pipelock:
|
||||
tls_passthrough: true
|
||||
```jsonc
|
||||
{
|
||||
"path": "/anthropic/",
|
||||
"upstream": "https://api.anthropic.com",
|
||||
"auth_scheme": "Bearer",
|
||||
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
||||
"role": "anthropic-base-url"
|
||||
}
|
||||
```
|
||||
|
||||
Routes that resolve to private or Tailscale addresses can opt into
|
||||
pipelock's SSRF destination allowlist explicitly:
|
||||
|
||||
```yaml
|
||||
egress:
|
||||
routes:
|
||||
- host: gitea.dideric.is
|
||||
auth:
|
||||
scheme: token
|
||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||
pipelock:
|
||||
ssrf_ip_allowlist:
|
||||
- 100.78.141.42/32
|
||||
```
|
||||
|
||||
At launch, `cli.py` reads `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` from the host
|
||||
At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host
|
||||
env and forwards it into the cred-proxy container's environ — never
|
||||
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
|
||||
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for
|
||||
@@ -499,7 +361,7 @@ the proxy strips and replaces the header on every request). `printenv`
|
||||
inside the agent does not surface the real token, and the value is
|
||||
never written to disk or placed on argv on the host.
|
||||
|
||||
A 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
|
||||
to the agent. Caveats: the token is bound to your subscription tier
|
||||
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
|
||||
@@ -509,7 +371,7 @@ via `claude setup-token` again. Reference:
|
||||
|
||||
## 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
|
||||
Code" are trademarks of Anthropic, PBC; the project name uses
|
||||
"claude" descriptively to indicate that the tool runs Claude Code
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
"""bot-bottle: Python implementation of the agent container launcher."""
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Docker-side egress helpers: port pin, in-container CA paths,
|
||||
container naming, and the host-side mitmproxy CA mint. The
|
||||
prepare-time routes-yaml rendering itself lives on the
|
||||
platform-neutral `Egress` ABC — backends instantiate it directly.
|
||||
|
||||
The per-container `.start()` / `.stop()` lifecycle was removed in
|
||||
PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
|
||||
under its python init supervisor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die
|
||||
|
||||
|
||||
# Listening port the egress daemon binds inside the bundle. The
|
||||
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
|
||||
# and the bundle's network aliases route `egress` to itself.
|
||||
EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099"))
|
||||
|
||||
# In-container path for mitmproxy's CA. The format is a single PEM
|
||||
# file holding BOTH the cert and the private key, concatenated. The
|
||||
# upstream-trust CA (pipelock's, so egress trusts the upstream
|
||||
# leg) is a separate file because pipelock keeps a different CA on
|
||||
# its end.
|
||||
EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER = (
|
||||
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
|
||||
)
|
||||
|
||||
|
||||
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
||||
|
||||
Returns `(mitmproxy_pem, cert_only_pem)`:
|
||||
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
||||
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
|
||||
- `cert_only_pem` is the cert alone — installed into the agent's
|
||||
trust store by `provision_ca` so the agent trusts the bumped
|
||||
CONNECT cert egress presents.
|
||||
|
||||
Why openssl req (not the pipelock binary's `tls init`):
|
||||
pipelock's CA generator stamps a non-standard `Subject Key
|
||||
Identifier` on the CA (random rather than SHA-1 of the pubkey).
|
||||
mitmproxy computes the `Authority Key Identifier` on each leaf
|
||||
it mints as SHA-1(issuer's pubkey). openssl's chain validator
|
||||
uses the leaf's AKI to find the issuer cert by SKI; pipelock's
|
||||
SKI doesn't match → openssl reports "unable to get local issuer
|
||||
certificate" even though the CA is right there in the trust
|
||||
store. openssl req's `subjectKeyIdentifier=hash` extension uses
|
||||
SHA-1(pubkey), matching mitmproxy's computation.
|
||||
|
||||
Both files live under `<stage_dir>/egress-ca/` (mode 644 —
|
||||
`docker cp` preserves the mode into the container, where the
|
||||
mitmproxy user (uid 1000) reads them; the host stage_dir is
|
||||
mode 700 so the private key isn't world-exposed)."""
|
||||
work = stage_dir / "egress-ca"
|
||||
work.mkdir(exist_ok=True)
|
||||
key_path = work / "ca-key.pem"
|
||||
cert_path = work / "ca.pem"
|
||||
cnf_path = work / "ca.cnf"
|
||||
|
||||
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
|
||||
# config matches RSA CAs without surprise), and openssl req's
|
||||
# default behavior here is exactly what we want.
|
||||
keygen = subprocess.run(
|
||||
["openssl", "genrsa", "-out", str(key_path), "2048"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if keygen.returncode != 0:
|
||||
die(f"egress ca keygen failed: {keygen.stderr.strip()}")
|
||||
# Standalone private key — never docker-cp'd, never bind-mounted
|
||||
# (mitmproxy reads the cert+key concat below). Lock to owner-
|
||||
# only so it doesn't sit at the default umask on disk.
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
|
||||
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
|
||||
# leaves it later mints. Without this, chain validation breaks
|
||||
# despite the CA being present in the trust store.
|
||||
cnf_path.write_text(
|
||||
"[req]\n"
|
||||
"distinguished_name = req_dn\n"
|
||||
"prompt = no\n"
|
||||
"x509_extensions = v3_ca\n"
|
||||
"\n"
|
||||
"[req_dn]\n"
|
||||
"O = bot-bottle\n"
|
||||
"CN = bot-bottle egress CA\n"
|
||||
"\n"
|
||||
"[v3_ca]\n"
|
||||
"basicConstraints = critical, CA:TRUE\n"
|
||||
"keyUsage = critical, keyCertSign, cRLSign\n"
|
||||
"subjectKeyIdentifier = hash\n"
|
||||
)
|
||||
cnf_path.chmod(0o644)
|
||||
|
||||
req = subprocess.run(
|
||||
["openssl", "req", "-x509", "-new", "-nodes",
|
||||
"-key", str(key_path),
|
||||
"-sha256", "-days", "365",
|
||||
"-config", str(cnf_path),
|
||||
"-out", str(cert_path)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if req.returncode != 0:
|
||||
die(f"egress ca cert generation failed: {req.stderr.strip()}")
|
||||
|
||||
cert_path.chmod(0o644)
|
||||
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||
# This file IS bind-mounted into the egress container (chunk 3+),
|
||||
# where mitmproxy runs as uid 1000 — so the host file has to be
|
||||
# world-readable for the container's user to read it through the
|
||||
# mount. Owner-only mode on the parent dir (state/<slug>/, under
|
||||
# ~/.bot-bottle which inherits ~'s 0o700) is what actually
|
||||
# restricts who can reach this file on the host.
|
||||
mitm = work / "mitmproxy-ca.pem"
|
||||
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||
mitm.chmod(0o644)
|
||||
return (mitm, cert_path)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Active-agent enumeration for the docker backend.
|
||||
|
||||
Mirrors `backend/smolmachines/enumerate.py`: returns
|
||||
`ActiveAgent` records the CLI `list active` command and the
|
||||
dashboard agents pane consume. Empty when docker isn't reachable
|
||||
— gated by `has_backend('docker')` at the cross-backend caller
|
||||
so this module trusts that docker is available when called.
|
||||
|
||||
The parser (`_parse_services_by_project`) is exposed for direct
|
||||
unit testing; the docker `docker ps` invocation is in
|
||||
`_query_services_by_project`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from .. import ActiveAgent
|
||||
from .bottle_state import read_metadata
|
||||
from .compose import compose_project_name, list_active_slugs
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
"""All currently-running docker-backed agents. Caller is
|
||||
responsible for gating on `has_backend('docker')` if it
|
||||
matters; if docker is missing the `docker ps` call below
|
||||
returns an empty list silently."""
|
||||
slugs = list_active_slugs(include_stopped=False, warn_on_error=False)
|
||||
if not slugs:
|
||||
return []
|
||||
services_by_project = _query_services_by_project()
|
||||
out: list[ActiveAgent] = []
|
||||
for slug in slugs:
|
||||
project = compose_project_name(slug)
|
||||
services = services_by_project.get(project, set())
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=tuple(sorted(services)),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_services_by_project(stdout: str) -> dict[str, set[str]]:
|
||||
"""Parse `docker ps` output formatted as
|
||||
`<project-label>\\t<service-label>` (one line per container)
|
||||
into a `{project: {service, ...}}` mapping. Pure function for
|
||||
testing — the docker invocation is in `_query_services_by_project`."""
|
||||
out: dict[str, set[str]] = {}
|
||||
for line in stdout.splitlines():
|
||||
project, _, service = line.partition("\t")
|
||||
if not project or not service:
|
||||
continue
|
||||
out.setdefault(project, set()).add(service)
|
||||
return out
|
||||
|
||||
|
||||
def _query_services_by_project() -> dict[str, set[str]]:
|
||||
"""One `docker ps` call → `{project: {service, ...}}`. Used
|
||||
by the CLI's `list active` and the dashboard's agents pane —
|
||||
one subprocess per refresh tick, not one per bottle."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[
|
||||
"docker", "ps",
|
||||
"--filter", "label=com.docker.compose.project",
|
||||
"--format",
|
||||
'{{.Label "com.docker.compose.project"}}'
|
||||
"\t"
|
||||
'{{.Label "com.docker.compose.service"}}',
|
||||
],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
if r.returncode != 0:
|
||||
return {}
|
||||
return _parse_services_by_project(r.stdout or "")
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Docker-side git-gate constants: in-container paths the renderer's
|
||||
bind-mounts target + the listening port. The prepare-time entrypoint
|
||||
/ hook render lives on the platform-neutral `GitGate` ABC — backends
|
||||
instantiate it directly. The git-gate daemon's container lifecycle
|
||||
is owned by the sidecar bundle (PRD 0024)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
||||
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
||||
|
||||
# git daemon's default listening port.
|
||||
GIT_GATE_PORT = 9418
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Docker-side pipelock helpers: image pin, container naming, and
|
||||
the one-shot `pipelock tls init` host-side CA mint. The
|
||||
prepare-time YAML rendering itself lives on the platform-neutral
|
||||
`PipelockProxy` ABC — backends instantiate it directly.
|
||||
|
||||
The per-container `.start()` / `.stop()` lifecycle was deleted in
|
||||
PRD 0024 chunk 3; compose-up owns the container lifecycle (PRD
|
||||
0018) and the bundle path (PRD 0024) collapses pipelock + egress
|
||||
+ git-gate + supervise into one container."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die
|
||||
# Re-exported for the compose renderer + smolmachines launch step
|
||||
# (they used to import these from this module before they moved to
|
||||
# the platform-neutral pipelock module).
|
||||
from ...pipelock import ( # noqa: F401
|
||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||
)
|
||||
|
||||
|
||||
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
||||
PIPELOCK_IMAGE = os.environ.get(
|
||||
"BOT_BOTTLE_PIPELOCK_IMAGE",
|
||||
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
||||
)
|
||||
|
||||
# Listening port for pipelock's forward proxy.
|
||||
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
|
||||
|
||||
|
||||
# The URL egress dials for its upstream HTTPS_PROXY. egress and
|
||||
# pipelock share the same container's network namespace inside the
|
||||
# sidecar bundle, so loopback reaches pipelock directly — no docker
|
||||
# DNS aliases involved.
|
||||
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
|
||||
|
||||
|
||||
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Generate a fresh per-bottle CA via a one-shot pipelock container.
|
||||
|
||||
Runs `pipelock tls init` against a host-mounted scratch dir, leaving
|
||||
`ca.pem` (public cert, mode 600) and `ca-key.pem` (private key, mode
|
||||
600) under `<stage_dir>/pipelock-ca/`. Returns the two host paths.
|
||||
|
||||
The image is pinned (same digest the running sidecar uses) so the
|
||||
generated CA matches what the sidecar expects. Output is owned by
|
||||
whatever UID the one-shot ran as; the compose renderer's
|
||||
bind-mounts pin the files in place at runtime, so ownership
|
||||
inside the running sidecar (root in pipelock's distroless image)
|
||||
is independent."""
|
||||
work = stage_dir / "pipelock-ca"
|
||||
work.mkdir(exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{work}:/h",
|
||||
"-e", "PIPELOCK_HOME=/h",
|
||||
PIPELOCK_IMAGE, "tls", "init"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"pipelock tls init failed: {result.stderr.strip()}")
|
||||
cert = work / "ca.pem"
|
||||
key = work / "ca-key.pem"
|
||||
if not cert.is_file() or not key.is_file():
|
||||
die(f"pipelock tls init did not produce ca files in {work}")
|
||||
# Explicit perms in case a future pipelock release changes
|
||||
# defaults. Pipelock runs as root in its distroless image and
|
||||
# bind-mounts work with 0o600 (root reads everything); the key
|
||||
# has no reason to be readable to anyone else on the host.
|
||||
key.chmod(0o600)
|
||||
cert.chmod(0o644)
|
||||
return (cert, key)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Sidecar bundle constants + helpers for the Docker backend
|
||||
(PRD 0024).
|
||||
|
||||
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
|
||||
runs pipelock + egress + git-gate + supervise as one container
|
||||
per bottle under a small Python init supervisor. As of chunk 5
|
||||
the bundle is the only shape — the legacy four-sidecar topology
|
||||
and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# Bundle image. Defaults to a built-locally tag (built from the
|
||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
||||
# pinning to a published digest can override via env, matching
|
||||
# the existing `BOT_BOTTLE_PIPELOCK_IMAGE` shape.
|
||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
||||
"bot-bottle-sidecars:latest",
|
||||
)
|
||||
|
||||
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
|
||||
|
||||
|
||||
def sidecar_bundle_container_name(slug: str) -> str:
|
||||
"""`bot-bottle-sidecars-<slug>`. Same prefix scheme as the
|
||||
per-sidecar containers it replaces, so the dashboard's
|
||||
discovery-by-prefix logic keeps working."""
|
||||
return f"bot-bottle-sidecars-{slug}"
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Shared print helpers for BottlePlan.print implementations.
|
||||
|
||||
Lifts the multi-value label printer out of DockerBottlePlan so the
|
||||
smolmachines backend (and any future backend) renders the same
|
||||
two-column scannable preflight without duplicating the indent
|
||||
math."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from ..log import info
|
||||
|
||||
|
||||
def print_multi(label: str, values: Sequence[str]) -> None:
|
||||
"""Print `label: <value>` with continuation lines indented to
|
||||
align under the first value. Empty `values` renders `(none)`.
|
||||
|
||||
Used by every backend's `BottlePlan.print` for env / skills /
|
||||
git / egress — one item per line keeps the preflight summary
|
||||
scannable when an agent has many of any of these."""
|
||||
if not values:
|
||||
info(f"{label}: (none)")
|
||||
return
|
||||
info(f"{label}: {values[0]}")
|
||||
indent = " " * (len(label) + 2)
|
||||
for v in values[1:]:
|
||||
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,15 +0,0 @@
|
||||
"""smolmachines bottle backend (PRD 0023).
|
||||
|
||||
Selectable via `BOT_BOTTLE_BACKEND=smolmachines`. Runs each
|
||||
bottle inside a per-agent microVM (libkrun / Hypervisor.framework
|
||||
on macOS) with a userspace gvproxy gateway as the egress
|
||||
primitive. The sidecar bundle (PRD 0024) runs as a host-side
|
||||
docker container reached only through gvproxy's port-forward list.
|
||||
|
||||
Chunk 1 (this commit) ships the backend skeleton + Smolfile +
|
||||
gvproxy renderers + preflight check. VM lifecycle, sidecar
|
||||
bringup, and provisioning land in later chunks."""
|
||||
|
||||
from .backend import SmolmachinesBottleBackend # noqa: F401
|
||||
|
||||
__all__ = ["SmolmachinesBottleBackend"]
|
||||
@@ -1,98 +0,0 @@
|
||||
"""SmolmachinesBottleBackend — the smolmachines implementation of
|
||||
BottleBackend (PRD 0023)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
from . import prepare as _prepare
|
||||
from . import smolvm as _smolvm
|
||||
from .bottle import SmolmachinesBottle
|
||||
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
||||
from .bottle_plan import SmolmachinesBottlePlan
|
||||
from .provision import ca as _ca
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import provider_auth as _provider_auth
|
||||
from .provision import skills as _skills
|
||||
from .provision import supervise as _supervise
|
||||
from .provision import workspace as _workspace
|
||||
|
||||
|
||||
class SmolmachinesBottleBackend(
|
||||
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
|
||||
):
|
||||
"""smolmachines backend. Selected by
|
||||
`BOT_BOTTLE_BACKEND=smolmachines`."""
|
||||
|
||||
name = "smolmachines"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""`smolvm` on PATH. The backend additionally needs macOS
|
||||
for libkrun + TSI, but `enumerate_active` / `cleanup` are
|
||||
host-shell ops that gracefully no-op on Linux too — the
|
||||
runtime check happens at `prepare`."""
|
||||
return _smolvm.is_available()
|
||||
|
||||
def _resolve_plan(
|
||||
self, spec: BottleSpec, *, stage_dir: Path
|
||||
) -> SmolmachinesBottlePlan:
|
||||
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
|
||||
|
||||
@contextmanager
|
||||
def launch(
|
||||
self, plan: SmolmachinesBottlePlan
|
||||
) -> Generator[SmolmachinesBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
def provision_ca(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_ca.provision_ca(plan, target)
|
||||
|
||||
def provision_prompt(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> str | None:
|
||||
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(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_skills.provision_skills(plan, target)
|
||||
|
||||
def provision_workspace(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_workspace.provision_workspace(plan, target)
|
||||
|
||||
def provision_git(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_git.provision_git(plan, target)
|
||||
|
||||
def provision_supervise(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_supervise.provision_supervise(plan, target)
|
||||
|
||||
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
||||
return _cleanup.prepare_cleanup()
|
||||
|
||||
def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None:
|
||||
_cleanup.cleanup(plan)
|
||||
|
||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||
return _enumerate.enumerate_active()
|
||||
@@ -1,169 +0,0 @@
|
||||
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
||||
|
||||
Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine
|
||||
exec` / `smolvm machine cp`. The handle is yielded by `launch`
|
||||
and torn down via the surrounding ExitStack on context exit;
|
||||
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
||||
context-manager contract is satisfied.
|
||||
|
||||
User context: `smolvm machine exec` runs commands as root in the
|
||||
VM, but the agent image's USER is `node` and agent CLIs may refuse
|
||||
to run as root in bypass modes. Both
|
||||
`exec_agent` and `exec` switch to the requested user (default
|
||||
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
||||
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
||||
(PAM session setup, /etc/profile sourcing) which can hang on a
|
||||
minimal Debian VM with no PAM session config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Mapping
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from . import pty_resize as _pty_resize
|
||||
from . import smolvm as _smolvm
|
||||
|
||||
|
||||
# Absolute path to the pty_resize wrapper. Invoke as
|
||||
# `python <path>` rather than `python -m <dotted-path>` so the
|
||||
# wrapper runs regardless of cwd / sys.path — it has no
|
||||
# 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
|
||||
# can't write to. Set HOME / USER explicitly through smolvm -e
|
||||
# so the child process sees them.
|
||||
_HOME_FOR = {
|
||||
"node": "/home/node",
|
||||
"root": "/root",
|
||||
}
|
||||
|
||||
|
||||
def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]:
|
||||
home = _HOME_FOR.get(user, f"/home/{user}")
|
||||
out = [f"HOME={home}", f"USER={user}"]
|
||||
for k, v in env.items():
|
||||
out.append(f"{k}={v}")
|
||||
return out
|
||||
|
||||
|
||||
class SmolmachinesBottle(Bottle):
|
||||
"""Handle returned by `SmolmachinesBottleBackend.launch`. The
|
||||
underlying VM lifecycle (create / start / stop / delete) lives
|
||||
on the launch ExitStack — this class only routes runtime
|
||||
operations to the right `smolvm machine ...` subcommand."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
machine_name: str,
|
||||
*,
|
||||
prompt_path: str | None = None,
|
||||
guest_env: Mapping[str, str] | None = None,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: PromptMode = "append_file",
|
||||
) -> None:
|
||||
self.name = machine_name
|
||||
# In-VM path to the agent's prompt file. None when the
|
||||
# agent declared no prompt (file still exists; we just
|
||||
# don't pass --append-system-prompt-file).
|
||||
self._prompt_path = prompt_path
|
||||
# Env vars the agent process needs (HTTPS_PROXY,
|
||||
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
|
||||
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
||||
# because exec doesn't inherit from machine_create's env.
|
||||
self._guest_env = dict(guest_env or {})
|
||||
self._agent_prompt_mode = agent_prompt_mode
|
||||
self.agent_command = agent_command
|
||||
self.agent_provider_template = (
|
||||
"codex" if agent_command == "codex" else "claude"
|
||||
)
|
||||
|
||||
def agent_argv(
|
||||
self, argv: list[str], *, tty: bool = True,
|
||||
) -> 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 /
|
||||
stderr) so the session feels native. Blocks until the agent
|
||||
exits; returns the in-VM exit code.
|
||||
|
||||
We bypass the captured-output `machine_exec` helper here
|
||||
because that one wraps stdout/stderr in pipes — fine for
|
||||
scripted exec, wrong for an interactive shell. Drop down
|
||||
to `subprocess.run` with the TTY inherited.
|
||||
|
||||
UID switches via `runuser -u node --` (not `-l`) so we
|
||||
avoid login-shell wiring. HOME / USER come from `smolvm
|
||||
-e` instead, which sets them on the process env."""
|
||||
return subprocess.run(
|
||||
self.agent_argv(argv, tty=tty), check=False,
|
||||
).returncode
|
||||
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
"""Run a POSIX shell script as `user` (default `node`) and
|
||||
capture the result. Matches the docker backend's `exec`,
|
||||
which defaults to the image's USER (also node) — so test
|
||||
helpers / provision shell-outs run with the same identity
|
||||
on both backends. Pass `user="root"` for tests that need
|
||||
root.
|
||||
|
||||
`runuser -u <user> -- env ... /bin/sh -c <script>` switches UID
|
||||
without invoking a login shell, then sets HOME / USER and the
|
||||
bottle env in the child process."""
|
||||
argv = [
|
||||
"--", "runuser", "-u", user, "--",
|
||||
"env", *_env_assignments_for(user, self._guest_env),
|
||||
"/bin/sh", "-c", script,
|
||||
]
|
||||
# Call smolvm directly because this path needs the host-side
|
||||
# subprocess capture shape used by the Docker backend.
|
||||
r = subprocess.run(
|
||||
["smolvm", "machine", "exec", "--name", self.name] + argv,
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
return ExecResult(
|
||||
returncode=r.returncode,
|
||||
stdout=r.stdout or "",
|
||||
stderr=r.stderr or "",
|
||||
)
|
||||
|
||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
||||
"""Copy a host path into the guest at `container_path`."""
|
||||
_smolvm.machine_cp(host_path, f"{self.name}:{container_path}")
|
||||
|
||||
def close(self) -> None:
|
||||
# Real teardown lives on the launch ExitStack; this is just
|
||||
# the idempotent alias the BottleBackend ABC expects.
|
||||
pass
|
||||
@@ -1,55 +0,0 @@
|
||||
"""SmolmachinesBottleCleanupPlan — concrete BottleCleanupPlan (issue #77).
|
||||
|
||||
Tracks the resources `SmolmachinesBottleBackend.cleanup` will
|
||||
remove:
|
||||
|
||||
- machines: smolvm machines whose name starts with
|
||||
`bot-bottle-` (running or stopped). Stopped +
|
||||
deleted via `smolvm machine stop` + `machine delete -f`.
|
||||
- bundles: docker containers `bot-bottle-sidecars-<slug>`
|
||||
left over from a smolmachines bottle (the bundle's
|
||||
port-forwards stay published on lo0 aliases until
|
||||
the container is gone). Removed via `docker rm -f`.
|
||||
- networks: docker networks `bot-bottle-bundle-<slug>`
|
||||
attached to the bundles. Removed via
|
||||
`docker network rm`.
|
||||
|
||||
Smolmachines state dirs live under the same `~/.bot-bottle/state/`
|
||||
path the docker backend uses; the docker backend's
|
||||
`prepare_cleanup` already enumerates orphan state dirs and is the
|
||||
single source of truth for that bucket (consults
|
||||
`enumerate_active_bottles()` so it doesn't reap a live
|
||||
smolmachines bottle's dir)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...log import info
|
||||
from .. import BottleCleanupPlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmolmachinesBottleCleanupPlan(BottleCleanupPlan):
|
||||
"""Resources SmolmachinesBottleBackend.cleanup will remove.
|
||||
Produced by `prepare_cleanup`; sorted so the y/N output is
|
||||
stable."""
|
||||
|
||||
machines: tuple[str, ...] = ()
|
||||
bundles: tuple[str, ...] = ()
|
||||
networks: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not self.machines and not self.bundles and not self.networks
|
||||
|
||||
def print(self) -> None:
|
||||
print(file=sys.stderr)
|
||||
for name in self.machines:
|
||||
info(f"smolvm machine: {name}")
|
||||
for name in self.bundles:
|
||||
info(f"bundle container:{name}")
|
||||
for name in self.networks:
|
||||
info(f"bundle network: {name}")
|
||||
print(file=sys.stderr)
|
||||
@@ -1,101 +0,0 @@
|
||||
"""SmolmachinesBottlePlan — concrete BottlePlan for the smolmachines
|
||||
backend (PRD 0023).
|
||||
|
||||
Slug + bundle docker subnet / gateway / pinned IP + smolvm
|
||||
machine name + agent `.smolmachine` artifact + per-bottle guest
|
||||
env. Provisioning fields (CA cert path, prompt path, etc.) land
|
||||
in chunk 4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import PromptMode
|
||||
from ...pipelock import PipelockProxyPlan
|
||||
from .. import BottlePlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmolmachinesBottlePlan(BottlePlan):
|
||||
"""Resolved fields the launch step needs to bring up the bottle.
|
||||
|
||||
Inherits `spec`, `stage_dir`, `git_gate_plan`, `egress_plan`,
|
||||
`supervise_plan`, and `agent_provision` from BottlePlan."""
|
||||
|
||||
slug: str
|
||||
# Per-bottle docker subnet for the sidecar bundle container.
|
||||
# The bundle runs at `bundle_ip` (always `.2`); the gateway is
|
||||
# at `.1`. smolvm's TSI allowlist is set to `bundle_ip/32`.
|
||||
bundle_subnet: str
|
||||
bundle_gateway: str
|
||||
bundle_ip: str
|
||||
# smolvm machine name + agent image source. machine_create
|
||||
# boots from a packed `.smolmachine` artifact (pre-baked at
|
||||
# prepare time via `smolvm pack create`); using `--from`
|
||||
# instead of `--image` avoids the registry-pull race we hit
|
||||
# when machine_start tried to fetch on-demand and the libkrun
|
||||
# agent's network attempt got refused by macOS.
|
||||
#
|
||||
# Chunk 2d ships with a public placeholder image (alpine)
|
||||
# since bot-bottle-claude:latest lives in the operator's local
|
||||
# docker daemon and smolvm's crane backend can't read from
|
||||
# there; chunk 4 resolves the agent-image-conversion gap
|
||||
# (push to a registry first, or smolvm grows a docker-daemon
|
||||
# transport).
|
||||
machine_name: str
|
||||
# Agent image ref (docker tag). `launch` runs the
|
||||
# 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
|
||||
# the guest has no DNS resolver inside the TSI allowlist.
|
||||
# Passed to `smolvm machine create` as `-e K=V` flags.
|
||||
# Smolfile-rendering is gone (smolvm 0.8.0's
|
||||
# `--smolfile` is mutually exclusive with `--from`, and
|
||||
# `--from` is the path that avoids the registry-pull race).
|
||||
guest_env: dict[str, str]
|
||||
# Path to the agent's prompt file on the host. Always written
|
||||
# (mode 0o600) so the in-VM path always exists; the file is
|
||||
# empty when the agent has no prompt — claude-code reads it
|
||||
# via --append-system-prompt-file only when non-empty.
|
||||
prompt_file: Path
|
||||
# Inner Plans for the sidecar bundle daemons. The same shape the
|
||||
# docker backend uses — same `.prepare()` calls produced
|
||||
# them — but our launch step doesn't populate the
|
||||
# docker-specific network fields (internal_network,
|
||||
# egress_network) because the smolmachines bundle isn't on
|
||||
# docker's `--internal` + egress bridge topology; it's on a
|
||||
# per-bottle bridge with a pinned IP. The unused fields stay
|
||||
# at their dataclass defaults.
|
||||
proxy_plan: PipelockProxyPlan
|
||||
# Agent-side endpoints. On Docker Desktop the docker bridge
|
||||
# IPs aren't reachable from the smolvm guest (TSI uses macOS
|
||||
# networking; docker container IPs live in the daemon's VM),
|
||||
# so the agent dials the bundle via host loopback +
|
||||
# docker-published random ports. Empty at prepare time;
|
||||
# launch populates these after bundle bringup via
|
||||
# `dataclasses.replace`. Format: a `host:port` for git-gate
|
||||
# (insteadOf URL prefix) + full URLs for proxy / supervise.
|
||||
agent_proxy_url: str = ""
|
||||
agent_git_gate_host: str = ""
|
||||
agent_supervise_url: str = ""
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def agent_dockerfile_path(self) -> str:
|
||||
return self.agent_provision.dockerfile
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Cleanup + active-listing for the smolmachines backend (issue #77).
|
||||
|
||||
`prepare_cleanup` enumerates leftover smolmachines resources:
|
||||
|
||||
- smolvm machines (`smolvm machine ls --json`) whose name starts
|
||||
with `bot-bottle-`.
|
||||
- bundle docker containers (`bot-bottle-sidecars-<slug>`).
|
||||
- bundle docker networks (`bot-bottle-bundle-<slug>`).
|
||||
|
||||
State dirs live under `~/.bot-bottle/state/<identity>/` —
|
||||
shared layout with the docker backend, which has the single
|
||||
orphan-state-dir enumerator (it already consults
|
||||
`enumerate_active_agents()` so a live smolmachines bottle's dir
|
||||
is preserved).
|
||||
|
||||
`cleanup` removes everything in the plan: stop + delete each VM,
|
||||
force-rm each container, rm each network. Each step is
|
||||
best-effort — a failure on one resource doesn't block the others."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from ...log import info, warn
|
||||
from . import sidecar_bundle as _bundle
|
||||
from . import smolvm as _smolvm
|
||||
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
||||
|
||||
|
||||
# Both names start with the same prefix the launcher uses.
|
||||
_VM_PREFIX = "bot-bottle-"
|
||||
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `bot-bottle-sidecars-`
|
||||
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `bot-bottle-bundle-`
|
||||
|
||||
|
||||
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
|
||||
"""Enumerate every smolmachines-owned resource on the host.
|
||||
No side effects. Returns an empty plan when smolvm isn't on
|
||||
PATH (no machines to reap) — `cleanup` is a no-op in that
|
||||
case too."""
|
||||
machines = _list_bot_bottle_machines()
|
||||
bundles = _list_bundle_containers()
|
||||
networks = _list_bundle_networks()
|
||||
return SmolmachinesBottleCleanupPlan(
|
||||
machines=tuple(sorted(machines)),
|
||||
bundles=tuple(sorted(bundles)),
|
||||
networks=tuple(sorted(networks)),
|
||||
)
|
||||
|
||||
|
||||
def cleanup(plan: SmolmachinesBottleCleanupPlan) -> None:
|
||||
"""Remove everything in the plan. Order matters: stop VMs
|
||||
first (they hold ports on lo0 aliases via libkrun), then the
|
||||
bundle containers (which hold the host port-forwards), then
|
||||
the networks (which docker won't reap until the containers
|
||||
are gone)."""
|
||||
for name in plan.machines:
|
||||
info(f"stopping smolvm machine {name}")
|
||||
subprocess.run(
|
||||
["smolvm", "machine", "stop", "--name", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
info(f"deleting smolvm machine {name}")
|
||||
r = subprocess.run(
|
||||
["smolvm", "machine", "delete", "-f", name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"smolvm machine delete -f {name} failed: "
|
||||
f"{(r.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
for name in plan.bundles:
|
||||
info(f"removing bundle container {name}")
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
for name in plan.networks:
|
||||
info(f"removing bundle network {name}")
|
||||
r = subprocess.run(
|
||||
["docker", "network", "rm", name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0 and "no such network" not in (r.stderr or "").lower():
|
||||
warn(
|
||||
f"docker network rm {name} failed: "
|
||||
f"{(r.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
|
||||
def _list_bot_bottle_machines() -> list[str]:
|
||||
"""All smolvm machines named `bot-bottle-*`, regardless of
|
||||
state (running / stopped / created). Empty when smolvm isn't
|
||||
installed."""
|
||||
if not _smolvm.is_available():
|
||||
return []
|
||||
r = subprocess.run(
|
||||
["smolvm", "machine", "ls", "--json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
machines = json.loads(r.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [
|
||||
m["name"] for m in machines
|
||||
if isinstance(m, dict)
|
||||
and m.get("name", "").startswith(_VM_PREFIX)
|
||||
]
|
||||
|
||||
|
||||
def _list_bundle_containers() -> list[str]:
|
||||
"""All docker containers named `bot-bottle-sidecars-*`,
|
||||
running or stopped. Empty when docker isn't installed."""
|
||||
# Late import: `backend/__init__` imports this module
|
||||
# transitively via the smolmachines backend.
|
||||
from .. import has_backend
|
||||
if not has_backend("docker"):
|
||||
return []
|
||||
r = subprocess.run(
|
||||
["docker", "ps", "-a",
|
||||
"--filter", f"name=^{_BUNDLE_PREFIX}",
|
||||
"--format", "{{.Names}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
line for line in (r.stdout or "").splitlines()
|
||||
if line and line.startswith(_BUNDLE_PREFIX)
|
||||
]
|
||||
|
||||
|
||||
def _list_bundle_networks() -> list[str]:
|
||||
"""All docker networks named `bot-bottle-bundle-*`. Empty
|
||||
when docker isn't installed."""
|
||||
from .. import has_backend
|
||||
if not has_backend("docker"):
|
||||
return []
|
||||
r = subprocess.run(
|
||||
["docker", "network", "ls",
|
||||
"--filter", f"name={_NETWORK_PREFIX}",
|
||||
"--format", "{{.Name}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
line for line in (r.stdout or "").splitlines()
|
||||
if line and line.startswith(_NETWORK_PREFIX)
|
||||
]
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Active-agent enumeration for the smolmachines backend (PRD
|
||||
0023 chunk 4 follow-up + issue #77).
|
||||
|
||||
Returns a list of `ActiveAgent` records — same shape the docker
|
||||
backend produces — so CLI `list active` and the dashboard agents
|
||||
pane render both backends through one code path.
|
||||
|
||||
A smolmachines agent is "active" when its smolvm guest is
|
||||
running. We cross-reference against the per-bottle sidecar
|
||||
bundle container to populate the `services` field (which daemons
|
||||
are up in the bundle); without a bundle we still surface the VM
|
||||
so the operator can see + clean it up.
|
||||
|
||||
The cross-backend caller gates on `has_backend("smolmachines")`
|
||||
and `has_backend("docker")`, so this module assumes both are
|
||||
available when called. Both subprocess calls below still
|
||||
tolerate "command not on PATH" defensively, but the gate is the
|
||||
intended access pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..docker.bottle_state import read_metadata
|
||||
from . import sidecar_bundle as _bundle
|
||||
|
||||
|
||||
# Smolvm VM names produced by prepare are `bot-bottle-<slug>`,
|
||||
# matching the bundle container name pattern. We use the prefix
|
||||
# both as a filter and to strip back to the slug.
|
||||
_VM_NAME_PREFIX = "bot-bottle-"
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
"""All currently-running smolmachines-backed agents. Empty
|
||||
list when no matching VMs are running. Caller is responsible
|
||||
for gating on `has_backend('smolmachines')` if needed; if
|
||||
smolvm is missing the `smolvm machine ls` call below returns
|
||||
nothing silently."""
|
||||
result = subprocess.run(
|
||||
["smolvm", "machine", "ls", "--json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
machines = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
services_by_slug = _query_bundle_services()
|
||||
out: list[ActiveAgent] = []
|
||||
for m in machines:
|
||||
name = m.get("name") or ""
|
||||
state = m.get("state") or ""
|
||||
if state != "running" or not name.startswith(_VM_NAME_PREFIX):
|
||||
continue
|
||||
slug = name[len(_VM_NAME_PREFIX):]
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveAgent(
|
||||
backend_name="smolmachines",
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=services_by_slug.get(slug, ()),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
||||
"""`{slug: ('egress', 'pipelock', ...)}` from each running
|
||||
bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var.
|
||||
Smolmachines bundles all run the PRD-0024 image with the
|
||||
same daemon set declared via env, so one inspect per bundle
|
||||
gets us the picture without exec'ing into the container.
|
||||
|
||||
Returns an empty mapping when the docker backend isn't
|
||||
available — the bundle services field on each ActiveAgent
|
||||
just shows up empty, matching the docker backend's "starting"
|
||||
state."""
|
||||
# Late import: `has_backend` lives on the backend package's
|
||||
# __init__, which imports this module transitively. Pulling
|
||||
# the name in at call time sidesteps the cycle.
|
||||
from .. import has_backend
|
||||
if not has_backend("docker"):
|
||||
return {}
|
||||
ps = subprocess.run(
|
||||
["docker", "ps",
|
||||
"--filter", "name=" + _bundle.bundle_container_name(""),
|
||||
"--format", "{{.Names}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if ps.returncode != 0:
|
||||
return {}
|
||||
out: dict[str, tuple[str, ...]] = {}
|
||||
for line in (ps.stdout or "").splitlines():
|
||||
name = line.strip()
|
||||
if not name:
|
||||
continue
|
||||
slug = name.removeprefix(_bundle.bundle_container_name(""))
|
||||
if not slug:
|
||||
continue
|
||||
inspect = subprocess.run(
|
||||
["docker", "inspect", name, "--format", "{{json .Config.Env}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if inspect.returncode != 0:
|
||||
continue
|
||||
try:
|
||||
env_list = json.loads(inspect.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for entry in env_list:
|
||||
key, _, value = entry.partition("=")
|
||||
if key == "BOT_BOTTLE_SIDECAR_DAEMONS":
|
||||
out[slug] = tuple(sorted(
|
||||
d for d in value.split(",") if d
|
||||
))
|
||||
break
|
||||
return out
|
||||
@@ -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
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Ephemeral local OCI registry for the smolmachines agent-image
|
||||
conversion path (PRD 0023 chunk 4c).
|
||||
|
||||
`smolvm pack create --image <ref>` only accepts OCI registry refs
|
||||
— it can't read the local docker daemon's image cache, an OCI
|
||||
layout directory, or a `docker save` tarball. To convert the
|
||||
agent's Dockerfile-built image into a `.smolmachine` artifact we
|
||||
spin up a short-lived `registry:2.8.3` container alongside a
|
||||
`crane` helper container on a private docker network, push via
|
||||
`crane push --insecure <tarball> <registry-container>:5000/...`,
|
||||
and let smolvm pull from the registry's published host port. The
|
||||
network + both containers are torn down after the pack completes.
|
||||
|
||||
Why this two-container dance instead of plain `docker push`:
|
||||
- Docker Desktop's daemon runs in its own Linux VM, so its
|
||||
`localhost` is not the host's loopback. A registry bound to
|
||||
the host's 127.0.0.1 is unreachable from the daemon side.
|
||||
- `host.docker.internal` is reachable from the daemon but isn't
|
||||
in Docker's default insecure-registries CIDRs (only `::1/128`
|
||||
and `127.0.0.0/8` are), so `docker push` to it tries HTTPS,
|
||||
hits a plain-HTTP registry, and dies with
|
||||
`http: server gave HTTP response to HTTPS client`. Adding
|
||||
`host.docker.internal` to daemon.json works but is a one-time
|
||||
manual step the user has to do in Docker Desktop's UI.
|
||||
- Going through a docker network sidesteps the host-vs-daemon
|
||||
loopback mismatch (crane and registry containers see each
|
||||
other on the network) AND the HTTPS preference (crane has an
|
||||
`--insecure` flag that forces plain HTTP).
|
||||
|
||||
The registry is also published on a random host port so smolvm
|
||||
— a host process — can pull from `localhost:<port>` via Docker's
|
||||
port-forward. smolvm's bundled crane auto-falls-back to HTTP for
|
||||
localhost addresses, so no insecure-registries config is needed
|
||||
on that side either."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
|
||||
from ...log import die
|
||||
|
||||
|
||||
# registry:2.8.3, pinned by digest. Same env-override pattern as the
|
||||
# pipelock image pin in bot_bottle/backend/docker/pipelock.py.
|
||||
REGISTRY_IMAGE = os.environ.get(
|
||||
"BOT_BOTTLE_REGISTRY_IMAGE",
|
||||
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||
)
|
||||
|
||||
|
||||
# gcr.io/go-containerregistry/crane:latest, pinned by digest. ~10MB,
|
||||
# stable upstream from Google; we only invoke `crane push --insecure`
|
||||
# against a localhost-equivalent registry, so the trust surface is
|
||||
# narrow.
|
||||
CRANE_IMAGE = os.environ.get(
|
||||
"BOT_BOTTLE_CRANE_IMAGE",
|
||||
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
|
||||
)
|
||||
|
||||
|
||||
# Internal port the registry binds to inside its container — fixed
|
||||
# by the registry:2 image. The host-side mapping is random.
|
||||
_REGISTRY_CONTAINER_PORT = "5000"
|
||||
|
||||
|
||||
# How long to wait for the registry's HTTP layer to bind before
|
||||
# giving up. Two seconds is empirically enough; 10s leaves headroom
|
||||
# for slow CI runners without making the failure mode chatty.
|
||||
_READY_TIMEOUT_S = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistryHandle:
|
||||
"""Everything callers need to push to + pull from the ephemeral
|
||||
registry.
|
||||
|
||||
`network` is the per-session docker network — a `crane push`
|
||||
container has to join it to reach the registry by name.
|
||||
`push_endpoint` is the `<host>:<port>` form to embed in image
|
||||
refs given to the crane push container (resolves via docker
|
||||
network DNS). `pull_endpoint` is the `<host>:<port>` form a
|
||||
host process (smolvm) uses; the registry's host port mapping
|
||||
backs this."""
|
||||
|
||||
network: str
|
||||
push_endpoint: str
|
||||
pull_endpoint: str
|
||||
|
||||
|
||||
@contextmanager
|
||||
def ephemeral_registry() -> Iterator[RegistryHandle]:
|
||||
"""Bring up a per-session docker network + a `registry:2.8.3`
|
||||
container on it (published on a random host port), yield a
|
||||
`RegistryHandle`, force-remove both on exit.
|
||||
|
||||
The container is started with `--rm` so a clean exit cleans up
|
||||
on its own; the `finally` block force-removes on abnormal exit
|
||||
(the calling process crashes between yield and close)."""
|
||||
session_id = uuid.uuid4().hex[:12]
|
||||
network = f"bot-bottle-registry-net-{session_id}"
|
||||
registry_name = f"bot-bottle-registry-{session_id}"
|
||||
|
||||
subprocess.run(
|
||||
["docker", "network", "create", network],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d", "--rm",
|
||||
"--name", registry_name,
|
||||
"--network", network,
|
||||
# `-p :5000` (no IP prefix) binds the container's
|
||||
# port 5000 on a random host port across all
|
||||
# interfaces. The host side reaches the registry
|
||||
# via this port — smolvm's `pack create` pulls from
|
||||
# `localhost:<port>` and the docker port-forward
|
||||
# routes there.
|
||||
"-p", _REGISTRY_CONTAINER_PORT,
|
||||
REGISTRY_IMAGE,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
try:
|
||||
port = _host_port(registry_name)
|
||||
_wait_ready(port)
|
||||
yield RegistryHandle(
|
||||
network=network,
|
||||
push_endpoint=f"{registry_name}:{_REGISTRY_CONTAINER_PORT}",
|
||||
pull_endpoint=f"localhost:{port}",
|
||||
)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", registry_name],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "network", "rm", network],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def crane_push_tarball(handle: RegistryHandle, tarball_path: str, ref: str) -> None:
|
||||
"""Run `crane push --insecure <tarball> <ref>` inside a one-shot
|
||||
container on the registry's docker network. `ref` should
|
||||
reference the registry by `handle.push_endpoint` so the crane
|
||||
container resolves it via docker network DNS.
|
||||
|
||||
Doesn't go through `docker push` to avoid the Docker-Desktop
|
||||
daemon's HTTPS preference for non-loopback hostnames — crane's
|
||||
`--insecure` flag forces plain HTTP, which is what the
|
||||
registry container speaks."""
|
||||
r = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm",
|
||||
"--network", handle.network,
|
||||
"-v", f"{tarball_path}:/img.tar:ro",
|
||||
CRANE_IMAGE,
|
||||
"push", "--insecure", "/img.tar", ref,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
die(
|
||||
f"crane push of {tarball_path!r} to {ref!r} failed: "
|
||||
f"{(r.stderr or r.stdout or '').strip() or '<no output>'}"
|
||||
)
|
||||
|
||||
|
||||
def _host_port(name: str) -> int:
|
||||
"""Resolve the host-side port docker mapped to the registry's
|
||||
container port. `docker port <name> 5000/tcp` returns one or
|
||||
more `host:port` lines (one per address family) — we take the
|
||||
first."""
|
||||
r = subprocess.run(
|
||||
["docker", "port", name, f"{_REGISTRY_CONTAINER_PORT}/tcp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
die(
|
||||
f"docker port {name} {_REGISTRY_CONTAINER_PORT}/tcp failed: "
|
||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
# `0.0.0.0:54321\n[::]:54321\n` — split on the last colon to
|
||||
# handle either IPv4 or IPv6 host syntax.
|
||||
line = (r.stdout or "").splitlines()[0].strip()
|
||||
_, _, port_str = line.rpartition(":")
|
||||
try:
|
||||
return int(port_str)
|
||||
except ValueError:
|
||||
die(f"unexpected `docker port` output: {line!r}")
|
||||
return -1 # unreachable; die() never returns
|
||||
|
||||
|
||||
def _wait_ready(port: int) -> None:
|
||||
"""Block until the registry's HTTP layer accepts a TCP
|
||||
connection on `127.0.0.1:<port>`, or `_READY_TIMEOUT_S`
|
||||
elapses.
|
||||
|
||||
A successful TCP connect is sufficient — registry:2.8.3 binds
|
||||
after it's ready to serve `/v2/` requests, so the push that
|
||||
follows will land on a working server. We probe loopback
|
||||
specifically (not via the docker network) because this helper
|
||||
runs on the host."""
|
||||
deadline = time.monotonic() + _READY_TIMEOUT_S
|
||||
last_err: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
||||
return
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
time.sleep(0.1)
|
||||
die(
|
||||
f"local registry on 127.0.0.1:{port} did not accept "
|
||||
f"connections within {_READY_TIMEOUT_S:.0f}s "
|
||||
f"(last error: {last_err})"
|
||||
)
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Per-bottle loopback alias allocation + TSI allowlist
|
||||
enforcement (PRD 0023, follow-up to PR #74).
|
||||
|
||||
After the pivot to host-loopback port-forwards, the smolmachines
|
||||
TSI allowlist was `127.0.0.1/32` — which meant the agent VM could
|
||||
reach **any** service bound to macOS's loopback, not just the
|
||||
bundle's published ports. Real downgrade from the docker
|
||||
backend's `--internal` network isolation.
|
||||
|
||||
This module narrows the allowlist by allocating each bottle a
|
||||
unique loopback alias (`127.0.0.16` .. `127.0.0.31`). The
|
||||
bundle's port-forwards bind to that alias, and the alias's /32
|
||||
is what TSI allows.
|
||||
|
||||
**Smolvm 0.8.0 quirk + workaround.** `smolvm machine create
|
||||
--from <smolmachine> --net --allow-cidr X/32` silently drops the
|
||||
flag — verified empirically that the agent process's allowlist
|
||||
ends up `null` in smolvm's persistent state DB (`~/Library/
|
||||
Application Support/smolvm/server/smolvm.db`, `vms` table,
|
||||
`data` BLOB), and the booted VM reaches all of `127.0.0.0/8`
|
||||
regardless of what we passed. Workaround: after machine_create,
|
||||
open the SQLite DB and patch the row's `allowed_cidrs` field
|
||||
directly. Smolvm reads the DB at machine_start, so the patched
|
||||
value takes effect on boot. Tested: enforcement is real — the
|
||||
guest's connect to a non-allowlisted IP fails with `Permission
|
||||
denied`. Other paths we tried (machine update, stop-edit-
|
||||
agent.config.json-restart, --smolfile, --image localhost:N/...)
|
||||
were dead ends.
|
||||
|
||||
macOS only configures `127.0.0.1` on `lo0` by default; the
|
||||
additional aliases require `sudo ifconfig lo0 alias`. We lazily
|
||||
sudo-add the missing pool on first use per boot — the aliases
|
||||
persist on `lo0` until reboot, so subsequent launches don't
|
||||
prompt.
|
||||
|
||||
Linux native daemons share the host's network namespace; the
|
||||
whole `127.0.0.0/8` is reachable by default and aliases are
|
||||
unnecessary. The pool logic detects native-Linux and skips sudo
|
||||
entirely; the DB patch is also gated on macOS.
|
||||
|
||||
Allocation is coordinated by inspecting running bundle
|
||||
containers' published host IPs — each bottle's bundle owns the
|
||||
alias appearing in its port bindings. The lowest-numbered free
|
||||
alias gets handed to a new bottle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
|
||||
# table holds one JSON BLOB per machine. The Linux path is
|
||||
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
|
||||
# we hard-code this. If the file moves under us we'll see a
|
||||
# clear FileNotFoundError; not worth defensive cross-platform
|
||||
# detection until the backend actually needs Linux.
|
||||
_SMOLVM_DB_PATH = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "smolvm"
|
||||
/ "server"
|
||||
/ "smolvm.db"
|
||||
)
|
||||
|
||||
|
||||
# Sixteen aliases by default. Tunable for hosts that want more
|
||||
# concurrent bottles (each bottle reserves one alias for its
|
||||
# bundle bringup). The range is chosen to avoid the reserved
|
||||
# 127.0.0.1/2/3 ports (1 is the default, 2 is sometimes used by
|
||||
# CUPS, 3 by other macOS services) and stay well clear of
|
||||
# 127.0.0.53 (systemd-resolved) and 127.0.0.54 (libvirt).
|
||||
_POOL_START = 16
|
||||
_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>.
|
||||
def _pool_addresses() -> list[str]:
|
||||
return [f"127.0.0.{i}" for i in range(_POOL_START, _POOL_END + 1)]
|
||||
|
||||
|
||||
def _is_macos() -> bool:
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def ensure_pool() -> None:
|
||||
"""Make sure each address in the pool is up on `lo0`. Lazily
|
||||
runs `sudo ifconfig lo0 alias <ip>/32 up` for missing entries
|
||||
(sudo prompts once, then the aliases persist on lo0 until
|
||||
reboot). No-op on non-macOS hosts."""
|
||||
if not _is_macos():
|
||||
return
|
||||
missing = [ip for ip in _pool_addresses() if not _alias_present(ip)]
|
||||
if not missing:
|
||||
return
|
||||
info(
|
||||
f"smolmachines needs {len(missing)} loopback alias(es) on lo0 "
|
||||
f"({', '.join(missing[:3])}{', ...' if len(missing) > 3 else ''}) "
|
||||
f"to scope per-bottle TSI allowlists. sudo will prompt once; "
|
||||
f"aliases persist until reboot."
|
||||
)
|
||||
for ip in missing:
|
||||
result = subprocess.run(
|
||||
["sudo", "-p", "bot-bottle (loopback alias): ",
|
||||
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"sudo ifconfig lo0 alias {ip} failed (exit "
|
||||
f"{result.returncode}). Re-run with sudo available, "
|
||||
f"or add manually: sudo ifconfig lo0 alias {ip}/32 up"
|
||||
)
|
||||
|
||||
|
||||
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
||||
"""Patch smolvm's persistent VM-state DB to set the machine's
|
||||
`allowed_cidrs` to the given list. Workaround for smolvm
|
||||
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
|
||||
|
||||
Must run AFTER `smolvm machine create` (the row has to
|
||||
exist) and BEFORE `smolvm machine start` (smolvm reads the
|
||||
row on start; in-flight VMs don't pick up changes). Once
|
||||
smolvm honors the CLI flag upstream this whole function is
|
||||
redundant — flag-respecting create + remove this call from
|
||||
launch.
|
||||
|
||||
No-op on non-macOS — the DB path differs and the Linux
|
||||
smolmachines code path isn't exercised in v1."""
|
||||
if not _is_macos():
|
||||
return
|
||||
if not _SMOLVM_DB_PATH.is_file():
|
||||
die(
|
||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
|
||||
f"smolvm 0.8.0 expected? `smolvm --version` to check."
|
||||
)
|
||||
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
|
||||
try:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
die(
|
||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
||||
f"machine_create must run before force_allowlist."
|
||||
)
|
||||
cfg = json.loads(row[0])
|
||||
cfg["allowed_cidrs"] = list(allowed_cidrs)
|
||||
# Write as BLOB (the column type smolvm uses) — passing a
|
||||
# plain str makes sqlite store it as Text and smolvm then
|
||||
# fails to read it.
|
||||
cur.execute(
|
||||
"UPDATE vms SET data = ? WHERE name = ?",
|
||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
||||
)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def allocate(slug: str) -> str:
|
||||
"""Pick the lowest-numbered alias from the pool not already
|
||||
in use by a running smolmachines bundle. Bails when the pool
|
||||
is exhausted — the caller should report the limit to the
|
||||
operator. `slug` is logged for traceability; not otherwise
|
||||
used (no on-disk reservation, allocation is purely
|
||||
docker-state-driven).
|
||||
|
||||
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.
|
||||
This still returns a deterministic address so launch.py's
|
||||
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():
|
||||
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()
|
||||
for ip in _pool_addresses():
|
||||
if ip not in in_use:
|
||||
return ip
|
||||
die(
|
||||
f"smolmachines loopback alias pool exhausted "
|
||||
f"({_POOL_END - _POOL_START + 1} aliases, all in use). "
|
||||
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
||||
f"raise _POOL_END in loopback_alias.py."
|
||||
)
|
||||
return "" # unreachable; die() never returns
|
||||
|
||||
|
||||
def _alias_present(ip: str) -> bool:
|
||||
"""True iff `ifconfig lo0` shows `<ip>` as an inet address.
|
||||
Exact-match — `127.0.0.1` shouldn't match `127.0.0.16`."""
|
||||
result = subprocess.run(
|
||||
["/sbin/ifconfig", "lo0"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
pattern = re.compile(rf"\binet {re.escape(ip)}\b")
|
||||
return bool(pattern.search(result.stdout or ""))
|
||||
|
||||
|
||||
def _aliases_in_use() -> set[str]:
|
||||
"""Aliases already bound by another smolmachines bundle's
|
||||
published-port mappings. We inspect every container whose
|
||||
name matches the smolmachines bundle prefix and pull the
|
||||
`HostIp` out of its port bindings."""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}",
|
||||
"--filter", "name=bot-bottle-sidecars-"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return set()
|
||||
names = [n.strip() for n in (result.stdout or "").splitlines() if n.strip()]
|
||||
in_use: set[str] = set()
|
||||
for name in names:
|
||||
in_use.update(_host_ips_for_container(name))
|
||||
return in_use
|
||||
|
||||
|
||||
def _host_ips_for_container(name: str) -> Iterable[str]:
|
||||
"""Yield the `HostIp` values across all port bindings on
|
||||
container `name`. A bundle binds three or four ports and
|
||||
they all share the same HostIp, so callers can take any."""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", name,
|
||||
"--format", "{{json .HostConfig.PortBindings}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ()
|
||||
try:
|
||||
bindings = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return ()
|
||||
seen: set[str] = set()
|
||||
for _port, mappings in (bindings or {}).items():
|
||||
for m in mappings or []:
|
||||
host_ip = m.get("HostIp") or ""
|
||||
if host_ip:
|
||||
seen.add(host_ip)
|
||||
return seen
|
||||
|
||||
|
||||
__all__ = ["allocate", "ensure_pool", "force_allowlist"]
|
||||
@@ -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,14 +0,0 @@
|
||||
"""Provisioning helpers for the smolmachines backend (PRD 0023
|
||||
chunk 4).
|
||||
|
||||
Each method maps onto one of `BottleBackend`'s `provision_*`
|
||||
overrides. They run after the VM is up + the bundle is reachable
|
||||
and copy host-side state (prompt, skills, .git, CA cert,
|
||||
supervise MCP config) into the guest via `smolvm machine cp` /
|
||||
`smolvm machine exec`.
|
||||
|
||||
Chunk 4a ships `provision_prompt` and `provision_skills` — the
|
||||
two that don't depend on agent-image tooling (claude-code,
|
||||
update-ca-certificates) beyond `cp` and `mkdir`. provision_ca /
|
||||
provision_git / provision_supervise land once the agent-image
|
||||
gap is solved."""
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Install the per-bottle MITM CA into the smolmachines guest's
|
||||
trust store (PRD 0023 chunk 4d).
|
||||
|
||||
Mirrors `backend.docker.provision.ca`: select the right CA (egress
|
||||
when the bottle has routes, else pipelock), `smolvm machine cp` it
|
||||
to Debian's `/usr/local/share/ca-certificates/` path,
|
||||
`update-ca-certificates` to rebuild the trust bundle, and log the
|
||||
fingerprint once. The selected cert depends on the agent's
|
||||
HTTP_PROXY target — same logic as the docker backend, since the
|
||||
agent dials the same daemons through the same bundle.
|
||||
|
||||
`smolvm machine exec` runs commands as root in the VM (no `-u`
|
||||
flag exists; the VM init is root), so we don't need the explicit
|
||||
`-u 0` the docker backend uses on its `docker exec` calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from ....log import die
|
||||
from ...util import (
|
||||
AGENT_CA_BUNDLE,
|
||||
AGENT_CA_PATH,
|
||||
log_ca_fingerprint,
|
||||
select_ca_cert,
|
||||
)
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
_SIGKILL_EXIT = 128 + 9
|
||||
|
||||
|
||||
def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Copy the agent-facing CA cert into the guest, rebuild the
|
||||
trust bundle, emit a one-line fingerprint log. Called from
|
||||
`BottleBackend.provision` after the smolvm guest is up."""
|
||||
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
||||
|
||||
_smolvm.machine_cp(str(cert_host_path), f"{target}:{AGENT_CA_PATH}")
|
||||
# Mode 0644 — readable to non-root tools in the guest.
|
||||
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
|
||||
# which is what curl / Python ssl / OpenSSL-based tools read by
|
||||
# default. The env trio (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /
|
||||
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
|
||||
# `requests` / libraries that don't load the system bundle.
|
||||
#
|
||||
r = _install_ca(target)
|
||||
if r.returncode == _SIGKILL_EXIT:
|
||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec
|
||||
# during early-VM provisioning. `update-ca-certificates`
|
||||
# is idempotent, so retry the same install once after a
|
||||
# short settle delay before treating it as fatal.
|
||||
time.sleep(1.0)
|
||||
r = _install_ca(target)
|
||||
|
||||
if r.returncode != 0:
|
||||
# update-ca-certificates not adding our cert is fatal —
|
||||
# claude-code's TLS handshake against the egress-MITM'd
|
||||
# api.anthropic.com would fail downstream. Bail early
|
||||
# with what we can see (output is captured by smolvm so
|
||||
# we can surface it).
|
||||
die(
|
||||
f"update-ca-certificates didn't add the agent CA "
|
||||
f"(exit {r.returncode}): "
|
||||
f"stdout={(r.stdout or '').strip()!r} "
|
||||
f"stderr={(r.stderr or '').strip()!r}"
|
||||
)
|
||||
|
||||
log_ca_fingerprint(cert_host_path, label)
|
||||
|
||||
|
||||
def _install_ca(target: str) -> _smolvm.SmolvmRunResult:
|
||||
# chown + chmod + update-ca-certificates + bundle
|
||||
# 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
|
||||
# constants live in the shared `backend.util` (Debian's
|
||||
# `update-ca-certificates` layout is the same in both backends).
|
||||
__all__ = ["AGENT_CA_BUNDLE", "AGENT_CA_PATH", "provision_ca"]
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Git provisioning inside a running smolmachines bottle
|
||||
(PRD 0023 chunk 4d).
|
||||
|
||||
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 transparently hits the per-bottle
|
||||
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 guest so
|
||||
the agent's commits are attributed to that identity.
|
||||
|
||||
Differs from `backend.docker.provision.git` in one address detail:
|
||||
the TSI-allowlisted guest can only reach the bundle's pinned IP
|
||||
(no DNS resolver in the /32 allowlist), so the insteadOf URLs
|
||||
are `http://<bundle_ip>:<port>/<name>.git` rather than the
|
||||
docker backend's `git://git-gate/<name>.git`. The render itself
|
||||
is the shared `git_gate_render_gitconfig` on the platform-neutral
|
||||
git_gate module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ....git_gate import git_gate_render_gitconfig
|
||||
from ....log import info
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
# `node` is the agent user from the repo Dockerfile. Override via
|
||||
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
||||
# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
|
||||
# transport.
|
||||
_DEFAULT_GUEST_HOME = "/home/node"
|
||||
|
||||
|
||||
def _guest_home() -> str:
|
||||
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||
|
||||
|
||||
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Set up git inside the guest. 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: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||
it into <guest_home>/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
|
||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||
host_git = str(workspace.host_path / ".git")
|
||||
info(f"copying {host_git} -> {target}:{guest_workspace_git}")
|
||||
# mkdir -p the workspace dir so `machine cp` lands the .git
|
||||
# directly there even on first-time bottles.
|
||||
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path])
|
||||
_smolvm.machine_cp(
|
||||
host_git, f"{target}:{guest_workspace_git}",
|
||||
)
|
||||
# `machine cp` lands files as root; the agent runs as node so
|
||||
# the workspace tree must be chowned over.
|
||||
_smolvm.machine_exec(
|
||||
target, ["chown", "-R", workspace.owner, guest_workspace_git],
|
||||
)
|
||||
|
||||
|
||||
def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Write ~/.gitconfig in the guest 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
|
||||
|
||||
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
||||
# HTTP port is published on host loopback at launch time so
|
||||
# the smolvm guest (which can only reach macOS networking via
|
||||
# TSI, not the docker bridge IP) can dial it. launch.py
|
||||
# populates `plan.agent_git_gate_host` after bundle bringup.
|
||||
content = git_gate_render_gitconfig(
|
||||
bottle.git, plan.agent_git_gate_host, scheme="http",
|
||||
)
|
||||
|
||||
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
||||
# Stage the file under the plan's stage_dir so `machine cp`
|
||||
# has a stable host path. The plan's stage_dir is cleaned up
|
||||
# by start.py's session-end teardown.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", dir=str(plan.stage_dir), prefix="gitconfig.",
|
||||
delete=False,
|
||||
) as f:
|
||||
f.write(content)
|
||||
config_file = Path(f.name)
|
||||
os.chmod(config_file, 0o600)
|
||||
|
||||
info(f"writing {guest_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
||||
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}")
|
||||
_smolvm.machine_exec(target, ["chown", "node:node", 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,
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Copy the agent prompt into a running smolmachines bottle.
|
||||
|
||||
The prompt file is always copied (so the in-guest path always
|
||||
exists) but `--append-system-prompt-file` only fires when the
|
||||
agent actually has a prompt — the return value signals which
|
||||
case, mirroring the docker backend's contract.
|
||||
|
||||
`smolvm machine cp` lands files as root inside the VM; the claude
|
||||
process runs as `node`, so we chown + chmod the prompt after the
|
||||
copy. Same flow as the docker backend's provision_prompt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
# `node` is the agent user from the repo Dockerfile.
|
||||
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
||||
# BOT_BOTTLE_CONTAINER_HOME knob.
|
||||
_DEFAULT_GUEST_HOME = "/home/node"
|
||||
|
||||
|
||||
def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
|
||||
"""Copy the prompt file into the running smolvm guest, fix
|
||||
ownership/mode. Returns the in-guest path if the agent has a
|
||||
non-empty prompt (drives --append-system-prompt-file), else
|
||||
None. The file is copied either way so the path always
|
||||
exists — mirrors the docker backend's behavior."""
|
||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
|
||||
# machine cp lands as root, source's 0o600 mode is preserved —
|
||||
# node can't read its own prompt without these two.
|
||||
_smolvm.machine_exec(target, ["chown", "node:node", in_guest_prompt_path])
|
||||
_smolvm.machine_exec(target, ["chmod", "600", in_guest_prompt_path])
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
return in_guest_prompt_path if agent.prompt else None
|
||||
@@ -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,63 +0,0 @@
|
||||
"""Copy host-side skill directories into a running smolmachines
|
||||
bottle.
|
||||
|
||||
Skills are validated on the host before launch by
|
||||
`BottleBackend._validate_skills`; this module assumes that
|
||||
validation has already run. A skill that disappears between
|
||||
validation and copy still dies loudly rather than silently
|
||||
producing a partial guest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from ....log import die, info
|
||||
from ...util import host_skill_dir
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
# In-guest path mirrors the docker backend's claude-skills
|
||||
# convention (~/.claude/skills/<name>/) under the node user's
|
||||
# home — same path as the real bot-bottle image's
|
||||
# /home/node/.claude/skills (pre-created in the Dockerfile).
|
||||
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
||||
|
||||
|
||||
def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Copy each of the agent's named skills from the host's
|
||||
~/.claude/skills/<name>/ into the guest's equivalent path.
|
||||
For each skill: `mkdir -p` the destination, `smolvm machine cp`
|
||||
the host source dir over, then chown the result to node:node so
|
||||
the agent can read it. No-op when the agent has no skills.
|
||||
|
||||
smolvm machine cp on a directory copies recursively (same
|
||||
semantics as `cp -r`); unlike docker cp's trailing-slash
|
||||
convention, smolvm doesn't need the `/.` suffix dance.
|
||||
|
||||
machine cp lands files as root inside the VM, so we chown each
|
||||
skill tree over to node:node after the copy — same pattern as
|
||||
the docker backend's provision_prompt."""
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
if not agent.skills:
|
||||
return
|
||||
|
||||
skills_dir = os.environ.get(
|
||||
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
||||
)
|
||||
|
||||
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
|
||||
|
||||
for name in agent.skills:
|
||||
src = host_skill_dir(name)
|
||||
if not os.path.isdir(src):
|
||||
die(
|
||||
f"skill {name!r} disappeared from host between "
|
||||
f"validation and copy at {src}."
|
||||
)
|
||||
dst = f"{skills_dir}/{name}"
|
||||
info(f"copying skill {name} into {target}:{dst}")
|
||||
# Wipe any prior copy so re-runs don't accumulate.
|
||||
_smolvm.machine_exec(target, ["rm", "-rf", dst])
|
||||
_smolvm.machine_cp(src, f"{target}:{dst}")
|
||||
_smolvm.machine_exec(target, ["chown", "-R", "node:node", dst])
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Supervise sidecar provisioning inside a running smolmachines
|
||||
bottle (PRD 0023 chunk 4d; PRD 0013 supervise plane).
|
||||
|
||||
Registers the per-bottle supervise sidecar as an HTTP MCP server
|
||||
in the agent's claude-code config so the agent discovers the
|
||||
stuck-recovery MCP tools (pipelock-block, capability-block) at
|
||||
startup.
|
||||
|
||||
Mirrors `backend.docker.provision.supervise` — same `claude mcp
|
||||
add` call, just dispatched via `smolvm machine exec` instead of
|
||||
`docker exec`, and against `<bundle_ip>:<port>` instead of the
|
||||
short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....log import info, warn
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Run `claude mcp add` inside the guest to register the
|
||||
supervise sidecar in claude-code's user config. No-op when
|
||||
bottle.supervise is False.
|
||||
|
||||
The URL is the agent-side endpoint launch.py populated after
|
||||
bundle bringup — `http://127.0.0.1:<host port>/` rather than
|
||||
the bundle's docker bridge IP, because that bridge isn't
|
||||
reachable from the smolvm guest on macOS.
|
||||
|
||||
Failure is logged but not fatal: the bottle still works (you
|
||||
just can't call supervise tools from the agent until the entry
|
||||
is added manually). The operator sees the warning at launch."""
|
||||
if plan.supervise_plan is None:
|
||||
return
|
||||
url = plan.agent_supervise_url
|
||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
||||
# `claude mcp add --scope user` writes to ~/.claude.json. The
|
||||
# agent is the `node` user; smolvm machine_exec runs as root
|
||||
# by default, so we have to switch user explicitly and set
|
||||
# HOME so the config lands in /home/node/.claude.json (where
|
||||
# the agent's claude actually reads it from).
|
||||
r = _smolvm.machine_exec(
|
||||
target,
|
||||
[
|
||||
"runuser", "-u", "node", "--",
|
||||
"env", "HOME=/home/node",
|
||||
"claude", "mcp", "add",
|
||||
"--scope", "user",
|
||||
"--transport", "http",
|
||||
_SUPERVISE_MCP_NAME,
|
||||
url,
|
||||
],
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"claude mcp add --scope user --transport http supervise {url}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["provision_supervise"]
|
||||
@@ -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:]))
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Per-bottle sidecar bundle bringup for the smolmachines backend
|
||||
(PRD 0023).
|
||||
|
||||
Two docker resources per bottle live here:
|
||||
|
||||
- **A dedicated bridge network**, subnet derived from the slug.
|
||||
The bundle container gets a pinned IP at `<subnet>.2` so the
|
||||
smolvm guest's TSI allowlist (`<bundle-ip>/32`) has a stable
|
||||
target. Without pinning, we'd have to inspect the container's
|
||||
assigned IP after start and feed it back into the Smolfile
|
||||
— a race we can sidestep with `--ip`.
|
||||
|
||||
- **The bundle container itself**, running the PRD 0024 bundle
|
||||
image (`bot-bottle-sidecars:latest` by default). Same
|
||||
image, same daemons, same daemon-private env / bind-mounts
|
||||
as the docker backend.
|
||||
|
||||
This module ships the lifecycle primitives only — create
|
||||
network, start bundle, stop bundle, remove network — wrapped
|
||||
around `subprocess.run(["docker", ...])`. Wiring them into the
|
||||
launch flow + populating the `BundleLaunchSpec` from the inner
|
||||
Plans (PipelockProxyPlan, EgressPlan, …) lands in chunk 2d."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from ...log import die, warn
|
||||
from ..docker import util as docker_mod
|
||||
from ..docker.sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
)
|
||||
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def bundle_network_name(slug: str) -> str:
|
||||
"""`bot-bottle-bundle-<slug>` — distinct from the docker
|
||||
backend's `bot-bottle-net-<slug>` so a smolmachines bottle
|
||||
and a docker bottle for the same agent don't collide on
|
||||
network name."""
|
||||
return f"bot-bottle-bundle-{slug}"
|
||||
|
||||
|
||||
def bundle_container_name(slug: str) -> str:
|
||||
"""`bot-bottle-sidecars-<slug>` — same name shape the docker
|
||||
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
|
||||
prefix-based discovery covers both backends with one filter."""
|
||||
return f"bot-bottle-sidecars-{slug}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleLaunchSpec:
|
||||
"""Everything `start_bundle` needs to bring up one bundle
|
||||
container. Populated by chunk-2d's launch flow from the inner
|
||||
Plans the prepare step already produces."""
|
||||
|
||||
slug: str
|
||||
network_name: str
|
||||
subnet: str
|
||||
gateway: str
|
||||
bundle_ip: str
|
||||
image: str = SIDECAR_BUNDLE_IMAGE
|
||||
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
|
||||
# supervisor inside the bundle reads it to skip
|
||||
# bottle-irrelevant daemons (e.g. supervise=False bottles).
|
||||
daemons_csv: str = "egress,pipelock"
|
||||
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
|
||||
# form inherits the value from the docker-run subprocess env,
|
||||
# matching the docker backend's compose-up secret-forwarding
|
||||
# pattern).
|
||||
environment: Sequence[str] = field(default_factory=tuple)
|
||||
# (host_path, container_path, read_only) bind mounts.
|
||||
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
|
||||
# Container ports to publish on `publish_host_ip`, random
|
||||
# host-side port per entry. The smolvm guest's TSI talks via
|
||||
# macOS networking, so docker container IPs (192.168.x.x in
|
||||
# the daemon's bridge) aren't directly reachable from the
|
||||
# guest — host-loopback port-forwards are. Egress's port
|
||||
# is bundle-internal and never published.
|
||||
ports_to_publish: Sequence[int] = field(default_factory=tuple)
|
||||
# Loopback IP to bind published ports against. Per-bottle
|
||||
# loopback aliases (`127.0.0.16` etc., added via sudo
|
||||
# ifconfig lo0 alias) narrow the TSI allowlist so a bottle
|
||||
# can't reach other bottles' (or other host services') ports
|
||||
# via 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:
|
||||
"""`docker network create` with an explicit subnet + gateway
|
||||
so the bundle's `--ip` lands on the address the Smolfile's
|
||||
TSI allowlist points at. Idempotent on the caller's side —
|
||||
`start_bundle` catches the "network exists" error and treats
|
||||
it as success (chunk-2d teardown is paired with each create).
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "create",
|
||||
"--subnet", subnet, "--gateway", gateway,
|
||||
network_name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Already-exists is fine on a resume path; everything else
|
||||
# is fatal — the bundle won't have an addressable network.
|
||||
if "already exists" in (result.stderr or "").lower():
|
||||
return
|
||||
die(
|
||||
f"docker network create {network_name} failed: "
|
||||
f"{(result.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
|
||||
def remove_bundle_network(network_name: str) -> None:
|
||||
"""Idempotent: a missing network returns success."""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "rm", network_name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
if "no such network" in (result.stderr or "").lower():
|
||||
return
|
||||
# Network with attached containers is the common non-fatal
|
||||
# case during a partial teardown — warn but don't die.
|
||||
warn(
|
||||
f"docker network rm {network_name} failed: "
|
||||
f"{(result.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
|
||||
def start_bundle(spec: BundleLaunchSpec, *,
|
||||
env: dict[str, str] | None = None) -> None:
|
||||
"""Bring the bundle container up on the per-bottle bridge with
|
||||
the pinned IP. Argv is built deterministically from `spec`;
|
||||
`env` is the host subprocess env (forwarded values for any
|
||||
bare-name entries in `spec.environment`)."""
|
||||
container = bundle_container_name(spec.slug)
|
||||
argv = [
|
||||
"docker", "run",
|
||||
"--name", container,
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--network", spec.network_name,
|
||||
"--ip", spec.bundle_ip,
|
||||
"-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
|
||||
]
|
||||
for entry in spec.environment:
|
||||
argv += ["-e", entry]
|
||||
for host_path, container_path, read_only in spec.volumes:
|
||||
suffix = ":ro" if read_only else ""
|
||||
argv += ["-v", f"{host_path}:{container_path}{suffix}"]
|
||||
# Loopback-only host port-forwards — the smolvm guest's TSI
|
||||
# uses macOS networking, and macOS loopback is the only host
|
||||
# surface that round-trips into Docker Desktop's daemon VM.
|
||||
# Binds to the per-bottle alias so TSI's IP-only allowlist
|
||||
# narrows reachability to this bottle's bundle only.
|
||||
for port in spec.ports_to_publish:
|
||||
argv += ["-p", f"{spec.publish_host_ip}::{port}"]
|
||||
argv.append(spec.image)
|
||||
result = subprocess.run(
|
||||
argv, capture_output=True, text=True,
|
||||
env=dict(env) if env is not None else None, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"docker run for bundle {container} failed: "
|
||||
f"{(result.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
|
||||
def bundle_host_port(
|
||||
slug: str, container_port: int, *, host_ip: str = "127.0.0.1",
|
||||
) -> int:
|
||||
"""`docker port <bundle> <container_port>/tcp` → the random
|
||||
host-side port docker assigned for the binding on `host_ip`.
|
||||
Called after `start_bundle` on each container port listed in
|
||||
`BundleLaunchSpec.ports_to_publish` so the launch step can
|
||||
build the agent's HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
|
||||
`<host_ip>:<host port>` form."""
|
||||
container = bundle_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["docker", "port", container, f"{container_port}/tcp"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"docker port {container} {container_port}/tcp failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
# Each line looks like `127.0.0.16:54321` — one per address
|
||||
# family / host IP. Match on the expected host_ip prefix so
|
||||
# bottles bound to per-bottle aliases pick the right line.
|
||||
for raw in (result.stdout or "").splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith(f"{host_ip}:"):
|
||||
_, _, port_str = line.rpartition(":")
|
||||
try:
|
||||
return int(port_str)
|
||||
except ValueError:
|
||||
die(f"unexpected `docker port` output: {line!r}")
|
||||
die(
|
||||
f"no port mapping on {host_ip} for {container} "
|
||||
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
||||
)
|
||||
return -1 # unreachable; die() never returns
|
||||
|
||||
|
||||
def stop_bundle(slug: str) -> None:
|
||||
"""Idempotent: a missing container returns success."""
|
||||
container = bundle_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["docker", "rm", "-f", container],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
if "no such container" in (result.stderr or "").lower():
|
||||
return
|
||||
warn(
|
||||
f"docker rm -f {container} failed: "
|
||||
f"{(result.stderr or '').strip()}"
|
||||
)
|
||||
@@ -1,247 +0,0 @@
|
||||
"""Thin subprocess wrapper around the `smolvm` CLI (PRD 0023).
|
||||
|
||||
One thin Python function per smolvm subcommand the launch flow
|
||||
needs. Two design choices worth flagging:
|
||||
|
||||
- **No daemon, no SDK.** smolvm 0.8.0 ships a `smolvm serve`
|
||||
HTTP API as the long-term-clean integration target. The
|
||||
project's stdlib-first ethos + the lower-overhead CLI calls
|
||||
push v1 to shell out via `subprocess.run`. If a future
|
||||
smolvm release makes `serve` mandatory (or significantly
|
||||
faster), revisit.
|
||||
|
||||
- **Two return shapes.** `SmolvmRunResult` (returncode + stdout
|
||||
+ stderr captured) is returned by `machine_exec` because the
|
||||
caller cares about the in-VM command's exit status, and by
|
||||
test helpers that introspect output. The other calls
|
||||
(`machine_start`, `machine_stop`, `pack_create`, etc.) raise
|
||||
`SmolvmError` on non-zero exit — failure to start a VM is
|
||||
fatal to the launch flow, not something callers want to
|
||||
branch on.
|
||||
|
||||
The wrapper is unit-tested with `subprocess.run` mocked; the
|
||||
integration smoke test (chunk 2d) exercises against a real
|
||||
smolvm binary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
|
||||
_SMOLVM = "smolvm"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmolvmRunResult:
|
||||
"""Captured result of an in-VM command. Mirrors the structure
|
||||
`Bottle.exec` returns so callers can hand it straight through."""
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class SmolvmError(RuntimeError):
|
||||
"""Raised when a smolvm subprocess returns non-zero on a path
|
||||
where the caller has no useful branch to take (start failed,
|
||||
pack failed, etc.). Carries the captured stderr for the
|
||||
operator-facing log line."""
|
||||
|
||||
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess):
|
||||
self.argv = list(argv)
|
||||
self.returncode = result.returncode
|
||||
self.stdout = result.stdout
|
||||
self.stderr = result.stderr
|
||||
cmd = " ".join(self.argv)
|
||||
super().__init__(
|
||||
f"{cmd!r} failed (exit {result.returncode}): "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _smolvm(*args: str, env: Mapping[str, str] | None = None,
|
||||
check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""One subprocess call into the smolvm CLI. `check=True`
|
||||
raises SmolvmError on non-zero; `check=False` returns the
|
||||
CompletedProcess for the caller to inspect."""
|
||||
argv = [_SMOLVM, *args]
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=dict(env) if env is not None else None,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise SmolvmError(argv, result)
|
||||
return result
|
||||
|
||||
|
||||
# --- Pack ----------------------------------------------------------------
|
||||
|
||||
|
||||
def pack_create(image: str, output: Path) -> None:
|
||||
"""`smolvm pack create --image <image> -o <output>`. Converts
|
||||
an OCI image into a self-contained `.smolmachine` artifact
|
||||
smolvm can boot via `machine create --from`. Idempotent on the
|
||||
smolvm side — re-running with the same image+output rebuilds
|
||||
from layer cache."""
|
||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
||||
|
||||
|
||||
# --- Machine lifecycle ---------------------------------------------------
|
||||
|
||||
|
||||
def machine_create(
|
||||
name: str,
|
||||
*,
|
||||
image: str | None = None,
|
||||
from_path: Path | None = None,
|
||||
allow_cidrs: Sequence[str] = (),
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
"""`smolvm machine create NAME [--image IMG | --from PATH]
|
||||
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is positional
|
||||
(the CLI's exception to the `--name` pattern other
|
||||
subcommands use).
|
||||
|
||||
`image` (registry ref like `alpine:latest`) and `from_path`
|
||||
(a `.smolmachine` artifact) are mutually exclusive — one or
|
||||
the other tells smolvm what to boot. The wrapper doesn't
|
||||
enforce exclusivity; smolvm errors clearly enough.
|
||||
|
||||
`allow_cidrs` and `env` are passed as CLI flags instead of a
|
||||
Smolfile because `--from` and `--smolfile` are themselves
|
||||
mutually exclusive in smolvm 0.8.0 — and we want `--from`'s
|
||||
no-pull-at-start property. The flag form gives the same
|
||||
result without the Smolfile complication.
|
||||
|
||||
`--net` is sent explicitly when `allow_cidrs` is non-empty.
|
||||
smolvm 0.8.0's docs say `--allow-cidr` implies `--net`, but
|
||||
empirically the implication only fires when no `--from` is
|
||||
set — `--from PATH --allow-cidr X/32` silently produces a
|
||||
machine with `network: false` and no routes in the guest, so
|
||||
the agent can't reach the bundle's pinned IP."""
|
||||
args: list[str] = ["machine", "create"]
|
||||
if image is not None:
|
||||
args += ["--image", image]
|
||||
if from_path is not None:
|
||||
args += ["--from", str(from_path)]
|
||||
if allow_cidrs:
|
||||
args.append("--net")
|
||||
for cidr in allow_cidrs:
|
||||
args += ["--allow-cidr", cidr]
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
args += ["-e", f"{k}={v}"]
|
||||
args.append(name)
|
||||
_smolvm(*args)
|
||||
|
||||
|
||||
def machine_start(name: str) -> None:
|
||||
"""`smolvm machine start --name NAME`."""
|
||||
_smolvm("machine", "start", "--name", name)
|
||||
|
||||
|
||||
def machine_stop(name: str) -> None:
|
||||
"""`smolvm machine stop --name NAME`. Idempotent against
|
||||
already-stopped machines: smolvm prints a notice and exits 0
|
||||
in that case, so no special handling here."""
|
||||
_smolvm("machine", "stop", "--name", name)
|
||||
|
||||
|
||||
def machine_delete(name: str) -> None:
|
||||
"""`smolvm machine delete -f NAME`. NAME is positional. `-f`
|
||||
skips the interactive confirmation — required for
|
||||
non-interactive teardown."""
|
||||
_smolvm("machine", "delete", "-f", name)
|
||||
|
||||
|
||||
def machine_exec(
|
||||
name: str,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
workdir: str | None = None,
|
||||
timeout: str | None = None,
|
||||
) -> SmolvmRunResult:
|
||||
"""`smolvm machine exec --name NAME [-w DIR] [--timeout DUR]
|
||||
[-e K=V ...] -- ARGV...`. Returns the captured result rather
|
||||
than raising — callers (including `Bottle.exec`) care about
|
||||
the in-VM command's exit code, not just whether smolvm ran.
|
||||
|
||||
`env` here is in-VM env vars (`-e K=V`), not the host
|
||||
subprocess env — smolvm's own argv carries them through the
|
||||
VMM."""
|
||||
flags: list[str] = ["machine", "exec", "--name", name]
|
||||
if workdir is not None:
|
||||
flags += ["-w", workdir]
|
||||
if timeout is not None:
|
||||
flags += ["--timeout", timeout]
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
flags += ["-e", f"{k}={v}"]
|
||||
# `--` separator before the command. smolvm's CLI requires it
|
||||
# so its own flag parser doesn't grab argv items that look
|
||||
# like flags.
|
||||
flags.append("--")
|
||||
flags += list(argv)
|
||||
result = _smolvm(*flags, check=False)
|
||||
return SmolvmRunResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout or "",
|
||||
stderr=result.stderr or "",
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
"""`smolvm machine cp SRC DST`. Path syntax: `machine:path` to
|
||||
reference a path inside the VM, bare path for the host. Both
|
||||
SRC and DST are positional; either side can be machine: or
|
||||
bare. Empty path is a no-op (returns immediately without
|
||||
invoking smolvm)."""
|
||||
if not src or not dst:
|
||||
return
|
||||
_smolvm("machine", "cp", src, dst)
|
||||
|
||||
|
||||
# --- Discovery -----------------------------------------------------------
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True iff `smolvm` is on PATH. Used by the integration test
|
||||
suite's skip-guards."""
|
||||
return shutil.which(_SMOLVM) is not None
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Slug / preflight / subnet helpers for the smolmachines backend
|
||||
(PRD 0023). Kept in its own module so the renderers can be
|
||||
unit-tested without importing the docker subprocess paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
|
||||
from ...log import die
|
||||
|
||||
|
||||
def smolmachines_preflight() -> None:
|
||||
"""Ensure `smolvm` is on PATH before the launch flow runs.
|
||||
Called from `_resolve_plan`; gives the operator a clear
|
||||
install pointer rather than a cryptic FileNotFoundError
|
||||
later. `gvproxy` is no longer required — see the PRD's design
|
||||
pivot section."""
|
||||
if shutil.which("smolvm") is not None:
|
||||
return
|
||||
die(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh"
|
||||
)
|
||||
|
||||
|
||||
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
||||
"""Derive a per-bottle docker subnet + gateway IP + bundle IP
|
||||
from the slug.
|
||||
|
||||
Returns `(subnet_cidr, gateway_ip, bundle_ip)`. The third
|
||||
octet comes from SHA-256 of the slug mod 254 (skipping 17 to
|
||||
avoid the docker-default bridge), so parallel bottles get
|
||||
distinct /24s and `resume` reuses the same /24. The bundle
|
||||
container always lands at `.2`; gateway is `.1`; the smolvm
|
||||
Smolfile's `allow_cidrs` is `<bundle_ip>/32`."""
|
||||
digest = hashlib.sha256(slug.encode("utf-8")).digest()
|
||||
octet = (digest[0] % 254) + 1
|
||||
# Skip the docker-default bridge to dodge the most common
|
||||
# collision (operators with `docker0` at 172.17.x.x or a
|
||||
# 192.168.17.x VPN client).
|
||||
if octet == 17:
|
||||
octet = 18
|
||||
subnet = f"192.168.{octet}.0/24"
|
||||
gateway = f"192.168.{octet}.1"
|
||||
bundle_ip = f"192.168.{octet}.2"
|
||||
return subnet, gateway, bundle_ip
|
||||
@@ -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]}...")
|
||||
@@ -1,64 +0,0 @@
|
||||
"""cleanup: stop and remove all orphaned bot-bottle resources.
|
||||
|
||||
Walks every registered backend (docker + smolmachines) so a single
|
||||
`./cli.py cleanup` reaps both backends' leftovers — orphaned
|
||||
smolvm machines won't survive a docker-only cleanup pass (issue
|
||||
addressed alongside #77).
|
||||
|
||||
Each backend's `prepare_cleanup` enumerates its own resources;
|
||||
docker's `_list_orphan_state_dirs` consults
|
||||
`enumerate_active_agents()` for the union of live identities so
|
||||
state dirs of running smolmachines bottles aren't reaped. State
|
||||
dirs are shared layout, so docker is the single owner of that
|
||||
bucket.
|
||||
|
||||
State dirs with `.preserve` are intentionally never touched — they
|
||||
hold capability-block rebuilds or crash snapshots the operator may
|
||||
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
||||
is the path for those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ..backend import get_bottle_backend, known_backend_names
|
||||
from ..log import info
|
||||
from ._common import read_tty_line
|
||||
|
||||
|
||||
def cmd_cleanup(_argv: list[str]) -> int:
|
||||
# Order: stable backend iteration so the y/N output is
|
||||
# deterministic across runs.
|
||||
plans = [
|
||||
(name, get_bottle_backend(name)) for name in known_backend_names()
|
||||
]
|
||||
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
|
||||
|
||||
if all(p.empty for _, _, p in prepared):
|
||||
info("no bot-bottle resources to clean up")
|
||||
return 0
|
||||
|
||||
for name, _, plan in prepared:
|
||||
if plan.empty:
|
||||
continue
|
||||
info(f"--- {name} backend ---")
|
||||
plan.print()
|
||||
|
||||
if not _prompt_yes("remove all of the above?"):
|
||||
info("cleanup: skipped")
|
||||
return 0
|
||||
|
||||
for name, backend, plan in prepared:
|
||||
if plan.empty:
|
||||
continue
|
||||
backend.cleanup(plan)
|
||||
info("cleanup: done")
|
||||
return 0
|
||||
|
||||
|
||||
def _prompt_yes(message: str) -> bool:
|
||||
sys.stderr.write(f"bot-bottle: {message} [y/N] ")
|
||||
sys.stderr.flush()
|
||||
reply = read_tty_line()
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
@@ -1,37 +0,0 @@
|
||||
"""list: list available agents or active bottles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from ..backend import enumerate_active_agents
|
||||
from ..manifest import Manifest
|
||||
from ._common import PROG, USER_CWD
|
||||
|
||||
|
||||
def cmd_list(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
|
||||
parser.add_argument("scope", choices=["available", "active"])
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.scope == "available":
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
for name in manifest.agents.keys():
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
# `active` enumerates every backend (docker + smolmachines)
|
||||
# so smolmachines bottles aren't hidden behind the env var.
|
||||
active = enumerate_active_agents()
|
||||
if not active:
|
||||
print("no active bot-bottle bottles", file=sys.stderr)
|
||||
return 0
|
||||
# One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`.
|
||||
# Tab-separated keeps the format stable for shell pipelines;
|
||||
# the dashboard renders the same data through its own
|
||||
# formatter.
|
||||
for b in active:
|
||||
services = ",".join(b.services) if b.services else "-"
|
||||
print(f"{b.backend_name}\t{b.slug}\t{b.agent_name}\t{services}")
|
||||
return 0
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
|
||||
#
|
||||
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
|
||||
# ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can
|
||||
# call it as a normal child. Behavior is unchanged:
|
||||
#
|
||||
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
|
||||
# to `--mode upstream:URL` to forward all post-MITM traffic
|
||||
# through pipelock. mitmproxy does NOT honor HTTPS_PROXY on
|
||||
# its outbound side, so the upstream wiring has to be the
|
||||
# mitmproxy mode flag, not env.
|
||||
# * Upstream trust: when EGRESS_UPSTREAM_CA is set, build a
|
||||
# combined trust bundle (system roots + pipelock CA) and point
|
||||
# mitmproxy at it. The option REPLACES mitmproxy's default
|
||||
# trust store, so passing pipelock's CA alone would break
|
||||
# route-configured pipelock passthrough hosts.
|
||||
# * `-s /app/egress_addon.py` loads the addon that reads
|
||||
# /etc/egress/routes.yaml.
|
||||
|
||||
set -e
|
||||
|
||||
# Pin mitmproxy's config dir to the bind-mount location of its CA
|
||||
# regardless of which user mitmdump runs as. In the legacy
|
||||
# four-sidecar setup (Dockerfile.egress, USER mitmproxy) this
|
||||
# resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024
|
||||
# bundle (USER root) `~root/.mitmproxy` is empty, so without this
|
||||
# flag mitmdump would generate a fresh CA on the wrong path and
|
||||
# the agent's installed trust anchor would no longer match the
|
||||
# bumped leaf certs.
|
||||
CONFDIR=/home/mitmproxy/.mitmproxy
|
||||
CONFDIR_FLAG="--set confdir=$CONFDIR"
|
||||
|
||||
MODE="--mode regular@9099"
|
||||
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
||||
MODE="--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099"
|
||||
fi
|
||||
|
||||
# Bind address. Docker backend wants `0.0.0.0` (agent dials egress
|
||||
# directly via the docker network alias). Smolmachines backend
|
||||
# wants `127.0.0.1` because the agent dials pipelock — not egress
|
||||
# — and egress is pipelock's localhost-only upstream inside the
|
||||
# bundle. TSI's IP-only allowlist would otherwise let the agent
|
||||
# reach `<bundle-ip>:9099` and bypass pipelock's DLP; binding
|
||||
# 127.0.0.1 inside the bundle closes that gap (PRD 0023 chunk 3).
|
||||
LISTEN_HOST_FLAG=""
|
||||
if [ -n "$EGRESS_LISTEN_HOST" ]; then
|
||||
LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST"
|
||||
fi
|
||||
|
||||
TRUST_FLAG=""
|
||||
if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then
|
||||
COMBINED=$CONFDIR/combined-trust.pem
|
||||
cat /etc/ssl/certs/ca-certificates.crt "$EGRESS_UPSTREAM_CA" > "$COMBINED"
|
||||
TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED"
|
||||
fi
|
||||
|
||||
# Scope the proxy env to this process tree only. In the bundle
|
||||
# image (PRD 0024) the four daemons share one container — setting
|
||||
# HTTPS_PROXY at the container level would route git-gate's git
|
||||
# pushes through pipelock, which is wrong (pipelock doesn't proxy
|
||||
# SSH and would block public git repos). Setting them here means
|
||||
# only mitmdump's subprocess inherits them. In the legacy
|
||||
# four-sidecar setup these env vars are also set in compose; here
|
||||
# they're additionally defensive.
|
||||
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
||||
export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY"
|
||||
export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY"
|
||||
export NO_PROXY="localhost,127.0.0.1"
|
||||
fi
|
||||
|
||||
exec mitmdump $CONFDIR_FLAG $MODE $LISTEN_HOST_FLAG $TRUST_FLAG -s /app/egress_addon.py
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}."
|
||||
)
|
||||
@@ -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
|
||||
@@ -1,546 +0,0 @@
|
||||
"""Pipelock sidecar lifecycle for the per-agent egress topology.
|
||||
|
||||
Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
|
||||
forward proxy with hostname allowlisting + DLP scanning + URL-entropy
|
||||
checks. One sidecar per agent, attached to the agent's --internal
|
||||
network and a per-agent user-defined egress bridge.
|
||||
|
||||
Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress
|
||||
(not pipelock); egress sets `HTTPS_PROXY=pipelock` on its
|
||||
outbound leg. So pipelock no longer sees the agent's connections
|
||||
directly — it sees the egress → upstream leg, applies the
|
||||
hostname allowlist + DLP body scan there, and forwards to the real
|
||||
upstream.
|
||||
|
||||
Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .egress import EGRESS_HOSTNAME, EgressRoute, egress_routes_for_bottle
|
||||
from .supervise import SUPERVISE_HOSTNAME
|
||||
from .manifest import Bottle
|
||||
|
||||
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
|
||||
# enabled. This is now route-owned manifest policy via
|
||||
# `egress.routes[].pipelock.tls_passthrough`; no provider hosts are
|
||||
# injected implicitly.
|
||||
DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# In-container paths the rendered pipelock YAML references under
|
||||
# `tls_interception`. The pipelock binary expects the per-bottle CA
|
||||
# cert + key at these exact paths inside its container — independent
|
||||
# of how the daemon is wrapped (own container, sidecar bundle, etc.),
|
||||
# which is why they live in the platform-neutral module.
|
||||
PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem"
|
||||
PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem"
|
||||
|
||||
|
||||
# Short network alias for pipelock inside the sidecar bundle. The
|
||||
# agent's HTTP_PROXY (when no egress is declared) and any in-bundle
|
||||
# consumer's URL both reference this name.
|
||||
PIPELOCK_HOSTNAME = "pipelock"
|
||||
|
||||
|
||||
# --- Allowlist resolution --------------------------------------------------
|
||||
|
||||
|
||||
def pipelock_effective_allowlist(
|
||||
bottle: Bottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> list[str]:
|
||||
"""Hostnames pipelock allows. Sorted for stability.
|
||||
|
||||
Always mirrors `egress_routes_for_bottle(bottle, provider_routes)` —
|
||||
egress is the single allowlist surface, and pipelock's allowlist is
|
||||
the downstream copy for defense-in-depth + DLP body scanning. For
|
||||
bottles without any `egress.routes[]` declared, this is empty except
|
||||
for supervise sidecar traffic when `supervise: true`.
|
||||
|
||||
The supervise sidecar's hostname is auto-added when supervise
|
||||
is enabled (sibling-sidecar traffic that flows through pipelock
|
||||
would otherwise be 403'd). Git upstreams declared in
|
||||
`bottle.git` do NOT contribute here — git traffic flows
|
||||
through git-gate (PRD 0008), not pipelock."""
|
||||
seen: dict[str, None] = {}
|
||||
for r in egress_routes_for_bottle(bottle, provider_routes):
|
||||
if r.host:
|
||||
seen.setdefault(r.host, None)
|
||||
if bottle.supervise:
|
||||
seen.setdefault(SUPERVISE_HOSTNAME, None)
|
||||
return sorted(seen.keys())
|
||||
|
||||
|
||||
def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
||||
"""Whether pipelock's BIP-39 seed-phrase detector stays on.
|
||||
|
||||
LLM conversation bodies legitimately trip the detector — any 12+
|
||||
English words that pass the BIP-39 checksum match — so agents can
|
||||
get blocked on ordinary prompts/responses regardless of provider
|
||||
(Claude, Codex/OpenAI, or future harnesses). We tried two narrower
|
||||
knobs first:
|
||||
|
||||
- `suppress: [{rule, path}]` — pipelock accepts the schema
|
||||
but the entry only silences the alert; the body_dlp block
|
||||
still fires.
|
||||
- `rules.disabled: ["dlp:BIP-39 Seed Phrase"]` — same shape,
|
||||
same outcome: 403 still returned.
|
||||
|
||||
Empirically only `seed_phrase_detection.enabled: false`
|
||||
actually stops the block (verified by sending a 12-word BIP-39
|
||||
body through three pipelock instances). It is a global toggle —
|
||||
no per-path / per-host knob in pipelock 2.3.0 — so we turn off
|
||||
only this detector for every bottle. The rest of pipelock's DLP
|
||||
defaults and request-body/header scanning remain enabled."""
|
||||
del bottle # kept for call-site stability and future policy knobs.
|
||||
return False
|
||||
|
||||
|
||||
def pipelock_effective_tls_passthrough(
|
||||
bottle: Bottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> list[str]:
|
||||
"""Hostnames pipelock should pass through (no TLS MITM).
|
||||
|
||||
A manifest route opts in with `pipelock.tls_passthrough: true`
|
||||
(lifted into `EgressRoute.tls_passthrough` in `egress_manifest_routes`).
|
||||
Provider routes that set `tls_passthrough=True` (e.g. Codex credential
|
||||
routes where egress injects the host bearer after the agent boundary)
|
||||
are also included. Both arrive via `egress_routes_for_bottle` — no
|
||||
provider-specific branching needed here.
|
||||
"""
|
||||
seen: dict[str, None] = {host: None for host in DEFAULT_TLS_PASSTHROUGH}
|
||||
for route in egress_routes_for_bottle(bottle, provider_routes):
|
||||
if route.tls_passthrough:
|
||||
seen.setdefault(route.host, None)
|
||||
return sorted(seen.keys())
|
||||
|
||||
|
||||
def pipelock_effective_ssrf_ip_allowlist(
|
||||
bottle: Bottle,
|
||||
extra: tuple[str, ...] = (),
|
||||
) -> list[str]:
|
||||
"""IP/CIDR entries that bypass pipelock's SSRF destination guard.
|
||||
|
||||
Launch code can pass backend-owned entries through `extra`, while
|
||||
route-owned entries come from `pipelock.ssrf_ip_allowlist`.
|
||||
"""
|
||||
seen: dict[str, None] = {ip: None for ip in extra}
|
||||
for route in bottle.egress.routes:
|
||||
for ip in route.Pipelock.SsrfIpAllowlist:
|
||||
seen.setdefault(ip, None)
|
||||
return sorted(seen.keys())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- Config build + YAML render --------------------------------------------
|
||||
|
||||
|
||||
def pipelock_build_config(
|
||||
bottle: Bottle,
|
||||
*,
|
||||
ca_cert_path: str = "",
|
||||
ca_key_path: str = "",
|
||||
ssrf_ip_allowlist: tuple[str, ...] = (),
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> dict[str, object]:
|
||||
"""Build the structured pipelock config dict the sidecar will load.
|
||||
|
||||
Deliberately carries no env values, no secrets, no per-agent
|
||||
customization beyond the resolved hostname list. The shape mirrors
|
||||
the YAML pipelock expects on disk; `pipelock_render_yaml` serializes
|
||||
it. Tests assert on this dict; production code renders it.
|
||||
|
||||
`ca_cert_path` / `ca_key_path` are the **in-container** paths the
|
||||
pipelock sidecar will read its CA from at runtime (they're
|
||||
populated into the container at start time via `docker cp`).
|
||||
Pass both or neither: both → emit `tls_interception` block with
|
||||
`enabled: true`; neither → omit the block entirely (pipelock
|
||||
falls back to its built-in default of `enabled: false`). Used
|
||||
by PRD 0006 to turn on pipelock's native TLS interception.
|
||||
|
||||
`ssrf_ip_allowlist` is the list of IPs / CIDRs that bypass
|
||||
pipelock's SSRF guard. Pipelock blocks RFC1918-resolved
|
||||
destinations by default, which would catch sibling-sidecar
|
||||
traffic on the bottle's internal Docker network in 172.x space
|
||||
(e.g. egress → pipelock on the upstream leg). Pass the
|
||||
bottle's internal network CIDR here so internal-network requests
|
||||
pass through pipelock while api_allowlist + body-scanning still
|
||||
apply. Empty by default; omitted from the rendered yaml when
|
||||
empty so pipelock keeps its built-in SSRF defaults."""
|
||||
cfg: dict[str, object] = {
|
||||
"version": 1,
|
||||
"mode": "strict",
|
||||
"enforce": True,
|
||||
"api_allowlist": pipelock_effective_allowlist(bottle, provider_routes),
|
||||
"forward_proxy": {"enabled": True},
|
||||
}
|
||||
if not pipelock_seed_phrase_detection_enabled(bottle):
|
||||
cfg["seed_phrase_detection"] = {"enabled": False}
|
||||
cfg["dlp"] = {"include_defaults": True, "scan_env": True}
|
||||
# Body-scan enforcement is a separate pipelock section (each DLP
|
||||
# "surface" — body, MCP, response — has its own action). Pipelock's
|
||||
# built-in default for request_body_scanning is "warn" (forward
|
||||
# with a log line); bot-bottle hard-codes "block" so a hit
|
||||
# actually stops the request from leaving the egress network.
|
||||
#
|
||||
# `scan_headers: true` + `header_mode: all` extends the scan to
|
||||
# every request header — pipelock's default `header_mode:
|
||||
# sensitive` only checks Authorization / Cookie / X-Api-Key /
|
||||
# X-Token / Proxy-Authorization / X-Goog-Api-Key, which an
|
||||
# agent attempting to exfil could trivially avoid by picking
|
||||
# a non-sensitive header name. "all" closes the gap; pipelock
|
||||
# caps it at the same max_body_bytes the body scan uses.
|
||||
cfg["request_body_scanning"] = {
|
||||
"action": "block",
|
||||
"scan_headers": True,
|
||||
"header_mode": "all",
|
||||
}
|
||||
if ca_cert_path or ca_key_path:
|
||||
if not (ca_cert_path and ca_key_path):
|
||||
raise ValueError(
|
||||
"pipelock_build_config: pass both ca_cert_path and ca_key_path "
|
||||
"to enable tls_interception, or neither to leave it off"
|
||||
)
|
||||
cfg["tls_interception"] = {
|
||||
"enabled": True,
|
||||
"ca_cert": ca_cert_path,
|
||||
"ca_key": ca_key_path,
|
||||
"passthrough_domains": pipelock_effective_tls_passthrough(bottle, provider_routes),
|
||||
}
|
||||
effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist(
|
||||
bottle, ssrf_ip_allowlist,
|
||||
)
|
||||
if effective_ssrf_ip_allowlist:
|
||||
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
|
||||
return cfg
|
||||
|
||||
|
||||
_PIPELOCK_TOP_LEVEL_KEYS = {
|
||||
"version",
|
||||
"mode",
|
||||
"enforce",
|
||||
"api_allowlist",
|
||||
"seed_phrase_detection",
|
||||
"forward_proxy",
|
||||
"dlp",
|
||||
"request_body_scanning",
|
||||
"tls_interception",
|
||||
"ssrf",
|
||||
}
|
||||
|
||||
|
||||
def _pipelock_render_error(section: str, key: str, expected: str) -> ValueError:
|
||||
return ValueError(
|
||||
f"pipelock_render_yaml: {section}.{key} must be {expected}"
|
||||
)
|
||||
|
||||
|
||||
def _reject_unknown_keys(
|
||||
section: str,
|
||||
obj: dict[str, object],
|
||||
allowed: set[str],
|
||||
) -> None:
|
||||
for key in sorted(set(obj) - allowed):
|
||||
raise ValueError(f"pipelock_render_yaml: {section}.{key} is unsupported")
|
||||
|
||||
|
||||
def _required_dict(
|
||||
obj: dict[str, object],
|
||||
section: str,
|
||||
key: str,
|
||||
) -> dict[str, object]:
|
||||
value = obj.get(key)
|
||||
if not isinstance(value, dict):
|
||||
raise _pipelock_render_error(section, key, "a mapping")
|
||||
return value
|
||||
|
||||
|
||||
def _required_bool(obj: dict[str, object], section: str, key: str) -> bool:
|
||||
value = obj.get(key)
|
||||
if not isinstance(value, bool):
|
||||
raise _pipelock_render_error(section, key, "a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _required_int(obj: dict[str, object], section: str, key: str) -> int:
|
||||
value = obj.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise _pipelock_render_error(section, key, "an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _required_str(obj: dict[str, object], section: str, key: str) -> str:
|
||||
value = obj.get(key)
|
||||
if not isinstance(value, str):
|
||||
raise _pipelock_render_error(section, key, "a string")
|
||||
return value
|
||||
|
||||
|
||||
def _required_str_list(
|
||||
obj: dict[str, object],
|
||||
section: str,
|
||||
key: str,
|
||||
) -> list[str]:
|
||||
value = obj.get(key)
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
raise _pipelock_render_error(section, key, "a list of strings")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_str_list(
|
||||
obj: dict[str, object],
|
||||
section: str,
|
||||
key: str,
|
||||
) -> list[str]:
|
||||
if key not in obj:
|
||||
return []
|
||||
return _required_str_list(obj, section, key)
|
||||
|
||||
|
||||
def _optional_bool(
|
||||
obj: dict[str, object],
|
||||
section: str,
|
||||
key: str,
|
||||
) -> bool | None:
|
||||
if key not in obj:
|
||||
return None
|
||||
return _required_bool(obj, section, key)
|
||||
|
||||
|
||||
def _optional_str(
|
||||
obj: dict[str, object],
|
||||
section: str,
|
||||
key: str,
|
||||
) -> str | None:
|
||||
if key not in obj:
|
||||
return None
|
||||
return _required_str(obj, section, key)
|
||||
|
||||
|
||||
def _validate_pipelock_render_config(cfg: dict[str, object]) -> dict[str, object]:
|
||||
_reject_unknown_keys("config", cfg, _PIPELOCK_TOP_LEVEL_KEYS)
|
||||
normalized: dict[str, object] = {
|
||||
"version": _required_int(cfg, "config", "version"),
|
||||
"mode": _required_str(cfg, "config", "mode"),
|
||||
"enforce": _required_bool(cfg, "config", "enforce"),
|
||||
"api_allowlist": _required_str_list(cfg, "config", "api_allowlist"),
|
||||
}
|
||||
|
||||
if "seed_phrase_detection" in cfg:
|
||||
spd = _required_dict(cfg, "config", "seed_phrase_detection")
|
||||
_reject_unknown_keys("seed_phrase_detection", spd, {"enabled"})
|
||||
normalized["seed_phrase_detection"] = {
|
||||
"enabled": _required_bool(spd, "seed_phrase_detection", "enabled"),
|
||||
}
|
||||
|
||||
fp = _required_dict(cfg, "config", "forward_proxy")
|
||||
_reject_unknown_keys("forward_proxy", fp, {"enabled"})
|
||||
normalized["forward_proxy"] = {
|
||||
"enabled": _required_bool(fp, "forward_proxy", "enabled"),
|
||||
}
|
||||
|
||||
dlp = _required_dict(cfg, "config", "dlp")
|
||||
_reject_unknown_keys("dlp", dlp, {"include_defaults", "scan_env"})
|
||||
normalized["dlp"] = {
|
||||
"include_defaults": _required_bool(dlp, "dlp", "include_defaults"),
|
||||
"scan_env": _required_bool(dlp, "dlp", "scan_env"),
|
||||
}
|
||||
|
||||
rbs = _required_dict(cfg, "config", "request_body_scanning")
|
||||
_reject_unknown_keys(
|
||||
"request_body_scanning",
|
||||
rbs,
|
||||
{"action", "scan_headers", "header_mode"},
|
||||
)
|
||||
normalized_rbs: dict[str, object] = {
|
||||
"action": _required_str(rbs, "request_body_scanning", "action"),
|
||||
}
|
||||
scan_headers = _optional_bool(rbs, "request_body_scanning", "scan_headers")
|
||||
if scan_headers is not None:
|
||||
normalized_rbs["scan_headers"] = scan_headers
|
||||
header_mode = _optional_str(rbs, "request_body_scanning", "header_mode")
|
||||
if header_mode is not None:
|
||||
normalized_rbs["header_mode"] = header_mode
|
||||
normalized["request_body_scanning"] = normalized_rbs
|
||||
|
||||
if "tls_interception" in cfg:
|
||||
tls = _required_dict(cfg, "config", "tls_interception")
|
||||
_reject_unknown_keys(
|
||||
"tls_interception",
|
||||
tls,
|
||||
{"enabled", "ca_cert", "ca_key", "passthrough_domains"},
|
||||
)
|
||||
normalized["tls_interception"] = {
|
||||
"enabled": _required_bool(tls, "tls_interception", "enabled"),
|
||||
"ca_cert": _required_str(tls, "tls_interception", "ca_cert"),
|
||||
"ca_key": _required_str(tls, "tls_interception", "ca_key"),
|
||||
"passthrough_domains": _optional_str_list(
|
||||
tls, "tls_interception", "passthrough_domains",
|
||||
),
|
||||
}
|
||||
|
||||
if "ssrf" in cfg:
|
||||
ssrf = _required_dict(cfg, "config", "ssrf")
|
||||
_reject_unknown_keys("ssrf", ssrf, {"ip_allowlist"})
|
||||
normalized["ssrf"] = {
|
||||
"ip_allowlist": _required_str_list(ssrf, "ssrf", "ip_allowlist"),
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def pipelock_render_yaml(cfg: dict[str, object]) -> str:
|
||||
"""Render a pipelock config dict (as produced by
|
||||
`pipelock_build_config`) as YAML. Hand-rolled so we don't take a
|
||||
YAML-parser dependency for a fixed, narrow shape."""
|
||||
def _bool(b: object) -> str:
|
||||
return "true" if b else "false"
|
||||
|
||||
cfg = _validate_pipelock_render_config(cfg)
|
||||
lines: list[str] = []
|
||||
lines.append(f"version: {cfg['version']}")
|
||||
lines.append(f"mode: {cfg['mode']}")
|
||||
lines.append(f"enforce: {_bool(cfg['enforce'])}")
|
||||
lines.append("")
|
||||
lines.append("api_allowlist:")
|
||||
api_allowlist = cfg["api_allowlist"]
|
||||
assert isinstance(api_allowlist, list)
|
||||
for h in api_allowlist:
|
||||
lines.append(f' - "{h}"')
|
||||
lines.append("")
|
||||
if "seed_phrase_detection" in cfg:
|
||||
lines.append("seed_phrase_detection:")
|
||||
spd = cfg["seed_phrase_detection"]
|
||||
assert isinstance(spd, dict)
|
||||
lines.append(f" enabled: {_bool(spd['enabled'])}")
|
||||
lines.append("")
|
||||
lines.append("forward_proxy:")
|
||||
fp = cfg["forward_proxy"]
|
||||
assert isinstance(fp, dict)
|
||||
lines.append(f" enabled: {_bool(fp['enabled'])}")
|
||||
lines.append("")
|
||||
lines.append("dlp:")
|
||||
dlp = cfg["dlp"]
|
||||
assert isinstance(dlp, dict)
|
||||
lines.append(f" include_defaults: {_bool(dlp['include_defaults'])}")
|
||||
lines.append(f" scan_env: {_bool(dlp['scan_env'])}")
|
||||
lines.append("")
|
||||
lines.append("request_body_scanning:")
|
||||
rbs = cfg["request_body_scanning"]
|
||||
assert isinstance(rbs, dict)
|
||||
lines.append(f' action: "{rbs["action"]}"')
|
||||
if "scan_headers" in rbs:
|
||||
lines.append(f" scan_headers: {_bool(rbs['scan_headers'])}")
|
||||
if "header_mode" in rbs:
|
||||
lines.append(f' header_mode: "{rbs["header_mode"]}"')
|
||||
if "tls_interception" in cfg:
|
||||
lines.append("")
|
||||
lines.append("tls_interception:")
|
||||
tls = cfg["tls_interception"]
|
||||
assert isinstance(tls, dict)
|
||||
lines.append(f" enabled: {_bool(tls['enabled'])}")
|
||||
lines.append(f' ca_cert: "{tls["ca_cert"]}"')
|
||||
lines.append(f' ca_key: "{tls["ca_key"]}"')
|
||||
passthrough = tls["passthrough_domains"]
|
||||
assert isinstance(passthrough, list)
|
||||
if passthrough:
|
||||
lines.append(" passthrough_domains:")
|
||||
for d in passthrough:
|
||||
lines.append(f' - "{d}"')
|
||||
if "ssrf" in cfg:
|
||||
lines.append("")
|
||||
lines.append("ssrf:")
|
||||
ssrf = cfg["ssrf"]
|
||||
assert isinstance(ssrf, dict)
|
||||
lines.append(" ip_allowlist:")
|
||||
ip_allowlist = ssrf["ip_allowlist"]
|
||||
assert isinstance(ip_allowlist, list)
|
||||
for ip in ip_allowlist:
|
||||
lines.append(f' - "{ip}"')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# --- Proxy class -----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipelockProxyPlan:
|
||||
"""Output of PipelockProxy.prepare; consumed by .start when the
|
||||
sidecar needs to be brought up.
|
||||
|
||||
yaml_path + slug are filled in at prepare time (host-side, side-
|
||||
effect-free; the YAML references the in-container CA paths
|
||||
already so it doesn't need the host paths to be valid). The
|
||||
remaining fields are populated by the backend's launch step
|
||||
via `dataclasses.replace`: internal/egress networks once
|
||||
those networks exist, the CA host paths once the one-shot
|
||||
`pipelock tls init` has run, and `internal_network_cidr` once
|
||||
Docker has assigned a subnet to the internal network. Empty
|
||||
defaults are sentinels meaning "not yet set"; `.start` validates
|
||||
that they are populated.
|
||||
|
||||
`internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist`
|
||||
so traffic from sibling sidecars (egress → pipelock on the
|
||||
upstream leg, etc.) bypasses pipelock's RFC1918 SSRF guard while
|
||||
api_allowlist and body-scanning still apply."""
|
||||
|
||||
yaml_path: Path
|
||||
slug: str
|
||||
internal_network: str = ""
|
||||
internal_network_cidr: str = ""
|
||||
egress_network: str = ""
|
||||
ca_cert_host_path: Path = Path()
|
||||
ca_key_host_path: Path = Path()
|
||||
|
||||
|
||||
class PipelockProxy:
|
||||
"""The pipelock egress proxy. Encapsulates the YAML-config
|
||||
generation; the container lifecycle is owned by whatever
|
||||
wraps the daemon (compose-managed pipelock container on docker,
|
||||
sidecar-bundle PID 1 on smolmachines).
|
||||
|
||||
Backends instantiate the class directly — there are no
|
||||
platform-specific subclasses; the in-container CA paths are
|
||||
universal module-level constants
|
||||
(`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`)."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
bottle: Bottle,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> PipelockProxyPlan:
|
||||
"""Write the pipelock yaml config (mode 600) under `stage_dir`
|
||||
and return the plan for launch. Pure host-side, no docker
|
||||
subprocess.
|
||||
|
||||
`slug` is the agent-derived identifier (lowercased,
|
||||
hyphen-normalized) used as the suffix in every per-agent
|
||||
resource name — the agent container, the sidecar bundle
|
||||
container, the internal/egress networks. It's stored on the
|
||||
returned plan so the backend's launch step can derive those
|
||||
names.
|
||||
|
||||
The CA paths the YAML references are the module-level
|
||||
in-container constants. The host-side counterparts are
|
||||
generated by the launch step (not here, so prepare stays
|
||||
side-effect-free on docker) and added to the plan via
|
||||
`dataclasses.replace` before the daemon starts."""
|
||||
yaml_path = stage_dir / "pipelock.yaml"
|
||||
cfg = pipelock_build_config(
|
||||
bottle,
|
||||
ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||
ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||
provider_routes=provider_routes,
|
||||
)
|
||||
yaml_path.write_text(pipelock_render_yaml(cfg))
|
||||
yaml_path.chmod(0o600)
|
||||
return PipelockProxyPlan(yaml_path=yaml_path, slug=slug)
|
||||
@@ -1,387 +0,0 @@
|
||||
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1).
|
||||
|
||||
PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns
|
||||
the configured daemons (egress, pipelock, git-gate, supervise),
|
||||
forwards SIGTERM/SIGINT to each child, and propagates per-daemon
|
||||
stdout+stderr to the container log with a `[name] ` prefix.
|
||||
|
||||
Failure policy (interim): when a child dies unexpectedly, the
|
||||
supervisor logs the death and leaves the surviving children
|
||||
running. The bundle stays up; whatever the dead daemon served
|
||||
will start failing, surfacing in the agent's own error path.
|
||||
The supervisor itself exits only when (a) the operator/compose
|
||||
sends SIGTERM/SIGINT, or (b) every child has died.
|
||||
|
||||
Failure policy (eventual): on unexpected death, the supervisor
|
||||
restarts the daemon and emits a notification to the supervise
|
||||
sidecar so the operator sees the event. That lands in a later
|
||||
PR; the interim policy is "don't take the bundle down for one
|
||||
sick daemon."
|
||||
|
||||
Daemon subset is env-driven. The compose renderer narrows it via
|
||||
`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that
|
||||
don't use git-gate or supervise. Default: all daemons.
|
||||
|
||||
Stdlib-only by design — adding supervisord/s6/runit for four
|
||||
daemons is heavier than this script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import IO, Sequence
|
||||
|
||||
|
||||
# Below compose's default 10s `stop_grace_period`. After this many
|
||||
# seconds past SIGTERM, escalate to SIGKILL on any still-running
|
||||
# child.
|
||||
_GRACE_SECONDS = 8.0
|
||||
|
||||
# Tight enough that exits and signals propagate without lag; loose
|
||||
# enough that the main loop isn't a CPU hog.
|
||||
_POLL_INTERVAL = 0.1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DaemonSpec:
|
||||
name: str
|
||||
argv: Sequence[str]
|
||||
|
||||
|
||||
# Env-var name prefixes that carry egress-only credentials.
|
||||
# `egress_apply.py` assigns `EGRESS_TOKEN_<n>` slots that egress
|
||||
# reads to inject `Authorization` headers on configured routes;
|
||||
# every other daemon in the bundle (especially pipelock with
|
||||
# `scan_env: true`) MUST NOT see these values or it'll match the
|
||||
# injected token in the request egress just sent and 403-block
|
||||
# the legitimate traffic (issue #84). The agent itself runs in a
|
||||
# different machine and never has access to these slots in the
|
||||
# first place, so stripping them from non-egress daemons loses no
|
||||
# DLP coverage — pipelock can't catch the exfil of a value the
|
||||
# agent doesn't have.
|
||||
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
|
||||
|
||||
|
||||
def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Egress sees the full bundle env. Everyone else gets a copy
|
||||
with `EGRESS_TOKEN_*` (and any other future egress-only
|
||||
credential slots) stripped. Returns a fresh dict — callers
|
||||
can mutate without affecting `base_env`."""
|
||||
if name == "egress":
|
||||
return dict(base_env)
|
||||
return {
|
||||
k: v for k, v in base_env.items()
|
||||
if not any(k.startswith(p) for p in _EGRESS_ONLY_ENV_PREFIXES)
|
||||
}
|
||||
|
||||
|
||||
# Order matters only for first-launch race-window reasons: egress
|
||||
# starts first so pipelock's upstream connect succeeds during
|
||||
# pipelock's own startup. git-gate and supervise are independent.
|
||||
# Pipelock binds 0.0.0.0:8888 explicitly. Without `--listen` it
|
||||
# defaults to 127.0.0.1 which would be unreachable from sibling
|
||||
# services on the docker network. The legacy four-sidecar
|
||||
# compose renderer passed the same flag; the bundle keeps the
|
||||
# explicit binding.
|
||||
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
||||
_DaemonSpec(
|
||||
"pipelock",
|
||||
("/usr/local/bin/pipelock", "run",
|
||||
"--config", "/etc/pipelock.yaml",
|
||||
"--listen", "0.0.0.0:8888"),
|
||||
),
|
||||
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
|
||||
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
||||
)
|
||||
|
||||
|
||||
def _selected_daemons(
|
||||
env: dict[str, str],
|
||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||
) -> tuple[_DaemonSpec, ...]:
|
||||
"""Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env
|
||||
var. Unknown names in the list are ignored — the renderer is the
|
||||
source of truth for which daemons are wired.
|
||||
|
||||
`all_daemons` defaults to `_DAEMONS` resolved at call time (not
|
||||
at definition time), so tests can monkey-patch the module-level
|
||||
`_DAEMONS` and have the new value take effect."""
|
||||
if all_daemons is None:
|
||||
all_daemons = _DAEMONS
|
||||
raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
|
||||
if not raw:
|
||||
return tuple(all_daemons)
|
||||
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
||||
return tuple(d for d in all_daemons if d.name in wanted)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
sys.stdout.write(f"sidecar-init: {msg}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _pump(name: str, stream: IO[bytes]) -> None:
|
||||
"""Read lines from `stream`, prefix with `[name]`, write to
|
||||
stdout. Runs in its own thread per child; daemon=True so a
|
||||
blocked read doesn't keep the process alive after main exits."""
|
||||
for raw in iter(stream.readline, b""):
|
||||
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
||||
sys.stdout.write(f"[{name}] {line}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _spawn(spec: _DaemonSpec) -> subprocess.Popen:
|
||||
proc = subprocess.Popen(
|
||||
list(spec.argv),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
env=_env_for_daemon(spec.name, dict(os.environ)),
|
||||
)
|
||||
threading.Thread(
|
||||
target=_pump, args=(spec.name, proc.stdout), daemon=True
|
||||
).start()
|
||||
return proc
|
||||
|
||||
|
||||
class _Supervisor:
|
||||
"""Holds the running children + shutdown state. Pulled out so
|
||||
the test suite can drive it with fake commands."""
|
||||
|
||||
def __init__(self, specs: Sequence[_DaemonSpec]):
|
||||
self.specs = tuple(specs)
|
||||
self.procs: list[tuple[_DaemonSpec, subprocess.Popen]] = []
|
||||
self.shutdown_at: float | None = None
|
||||
# Names of children that have been logged as having exited
|
||||
# so we only log each death once across watch-loop ticks.
|
||||
self._logged_dead: set[str] = set()
|
||||
# Signal handlers add daemon names here and return quickly.
|
||||
# The main watch loop drains the set, so repeated restart
|
||||
# requests for one daemon coalesce into one restart.
|
||||
self._restart_requested: set[str] = set()
|
||||
|
||||
def start_all(self) -> None:
|
||||
for spec in self.specs:
|
||||
_log(f"starting {spec.name}")
|
||||
self.procs.append((spec, _spawn(spec)))
|
||||
|
||||
def request_shutdown(self, reason: str) -> None:
|
||||
if self.shutdown_at is not None:
|
||||
return
|
||||
self.shutdown_at = time.monotonic()
|
||||
self._restart_requested.clear()
|
||||
_log(f"shutting down ({reason}); forwarding SIGTERM")
|
||||
for _, p in self.procs:
|
||||
if p.poll() is None:
|
||||
try:
|
||||
p.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def request_restart(self, daemon_name: str) -> bool:
|
||||
"""Queue a daemon restart for the main loop to process.
|
||||
|
||||
Signal handlers use this non-blocking path instead of doing
|
||||
subprocess lifecycle work directly. Requests coalesce by
|
||||
daemon name: one pending restart is enough to make the daemon
|
||||
reread the latest config from disk.
|
||||
|
||||
Returns True iff a daemon by that name is known to the
|
||||
supervisor and shutdown has not started."""
|
||||
if self.shutdown_at is not None:
|
||||
_log(f"restart {daemon_name} skipped; supervisor is shutting down")
|
||||
return False
|
||||
if not any(spec.name == daemon_name for spec, _ in self.procs):
|
||||
return False
|
||||
self._restart_requested.add(daemon_name)
|
||||
return True
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""One iteration of the watch loop. Returns True when every
|
||||
child has exited and the supervisor can return.
|
||||
|
||||
A child dying unexpectedly is logged but does NOT initiate
|
||||
shutdown — see the module docstring's failure-policy
|
||||
section. Shutdown is signal-driven only."""
|
||||
self._drain_restart_requests()
|
||||
|
||||
for spec, p in self.procs:
|
||||
rc = p.poll()
|
||||
if rc is None or spec.name in self._logged_dead:
|
||||
continue
|
||||
self._logged_dead.add(spec.name)
|
||||
if self.shutdown_at is None:
|
||||
_log(
|
||||
f"{spec.name} exited with code {rc}; leaving "
|
||||
f"surviving daemons running (operator-visible "
|
||||
f"via agent-side failure)"
|
||||
)
|
||||
else:
|
||||
_log(f"{spec.name} exited with code {rc}")
|
||||
|
||||
if self.shutdown_at is not None:
|
||||
elapsed = time.monotonic() - self.shutdown_at
|
||||
if elapsed > _GRACE_SECONDS:
|
||||
still_running = [
|
||||
spec.name for spec, p in self.procs if p.poll() is None
|
||||
]
|
||||
if still_running:
|
||||
_log(
|
||||
f"grace ({_GRACE_SECONDS:.0f}s) elapsed; SIGKILL on "
|
||||
f"{', '.join(still_running)}"
|
||||
)
|
||||
for _, p in self.procs:
|
||||
if p.poll() is None:
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
done = all(p.poll() is not None for _, p in self.procs)
|
||||
if done:
|
||||
for _, p in self.procs:
|
||||
if p.stdout is not None:
|
||||
p.stdout.close()
|
||||
return done
|
||||
|
||||
def exit_code(self) -> int:
|
||||
"""Positive child failures win; otherwise report success.
|
||||
|
||||
Python represents signal-terminated children as negative
|
||||
return codes. A signal-only graceful shutdown should not leak
|
||||
that platform-specific detail into the container exit status,
|
||||
but a positive crash before shutdown should remain visible."""
|
||||
positives = [
|
||||
p.returncode for _, p in self.procs
|
||||
if p.returncode is not None and p.returncode > 0
|
||||
]
|
||||
return max(positives, default=0)
|
||||
|
||||
def _drain_restart_requests(self) -> None:
|
||||
if self.shutdown_at is not None:
|
||||
self._restart_requested.clear()
|
||||
return
|
||||
requested = tuple(sorted(self._restart_requested))
|
||||
self._restart_requested.clear()
|
||||
for daemon_name in requested:
|
||||
if self.shutdown_at is not None:
|
||||
self._restart_requested.clear()
|
||||
return
|
||||
self.restart_daemon(daemon_name)
|
||||
|
||||
def forward_signal(self, sig: int, daemon_name: str) -> bool:
|
||||
"""Forward a signal to one named child. Used by the SIGHUP
|
||||
path: egress_apply.py runs `docker kill --signal HUP
|
||||
<bundle>`, the host kernel delivers SIGHUP to PID 1 (this
|
||||
supervisor), and we relay it to mitmdump so it reloads
|
||||
its addon's routes.yaml. Returns True iff a live child by
|
||||
that name was signaled."""
|
||||
for spec, p in self.procs:
|
||||
if spec.name != daemon_name:
|
||||
continue
|
||||
if p.poll() is not None:
|
||||
_log(
|
||||
f"SIGHUP for {daemon_name} dropped; daemon "
|
||||
f"already exited (rc={p.returncode})"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
p.send_signal(sig)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
_log(f"forwarded {signal.Signals(sig).name} to {daemon_name}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool:
|
||||
"""Terminate one named child and spawn a fresh one, leaving
|
||||
the other daemons running. Used by the pipelock-apply path:
|
||||
pipelock has no in-process reload, so apply_allowlist_change
|
||||
runs `docker kill --signal USR1 <bundle>` after writing the
|
||||
new yaml; the supervisor catches SIGUSR1 and calls this.
|
||||
|
||||
Behavior: SIGTERM → wait up to `grace` seconds → SIGKILL if
|
||||
still alive → spawn a replacement under the same DaemonSpec.
|
||||
The `procs` slot is updated in place so subsequent
|
||||
forward_signal / shutdown calls reach the new pid.
|
||||
|
||||
Returns True iff a daemon by that name was running and a
|
||||
replacement spawned; False if no such daemon (the
|
||||
compose-renderer subset said this bottle doesn't run it)."""
|
||||
if self.shutdown_at is not None:
|
||||
_log(f"restart {daemon_name} skipped; supervisor is shutting down")
|
||||
return False
|
||||
for idx, (spec, p) in enumerate(self.procs):
|
||||
if spec.name != daemon_name:
|
||||
continue
|
||||
_log(f"restarting {daemon_name}")
|
||||
if p.poll() is None:
|
||||
try:
|
||||
p.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
p.wait(timeout=grace)
|
||||
except subprocess.TimeoutExpired:
|
||||
_log(
|
||||
f"{daemon_name} did not exit within {grace:.0f}s "
|
||||
f"of SIGTERM; SIGKILL"
|
||||
)
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
p.wait()
|
||||
if p.stdout is not None:
|
||||
p.stdout.close()
|
||||
self._logged_dead.discard(daemon_name)
|
||||
new_proc = _spawn(spec)
|
||||
self.procs[idx] = (spec, new_proc)
|
||||
_log(f"{daemon_name} restarted (pid {new_proc.pid})")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
del argv # no flags yet; env-driven only
|
||||
specs = _selected_daemons(dict(os.environ))
|
||||
if not specs:
|
||||
_log("no daemons selected; nothing to do")
|
||||
return 0
|
||||
|
||||
sup = _Supervisor(specs)
|
||||
sup.start_all()
|
||||
|
||||
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM"))
|
||||
signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT"))
|
||||
# SIGHUP reload path: egress_apply.py runs `docker kill
|
||||
# --signal HUP <bundle>` after writing routes.yaml. The kernel
|
||||
# delivers SIGHUP to PID 1 (this supervisor); forward it to
|
||||
# mitmdump so it reloads its addon.
|
||||
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress"))
|
||||
# SIGUSR1 pipelock-restart path: pipelock_apply.py runs
|
||||
# `docker kill --signal USR1 <bundle>` after writing
|
||||
# pipelock.yaml. Pipelock has no in-process reload, so the
|
||||
# supervisor restarts the pipelock daemon in place (other
|
||||
# daemons keep running — specifically supervise, whose MCP
|
||||
# socket would drop on a whole-container `docker restart`).
|
||||
signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock"))
|
||||
|
||||
while not sup.tick():
|
||||
time.sleep(_POLL_INTERVAL)
|
||||
|
||||
rc = sup.exit_code()
|
||||
_log(f"exit {rc}")
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""claude-bottle: Python implementation of the agent container launcher."""
|
||||
@@ -19,35 +19,26 @@ backend exposes five methods:
|
||||
cleanup(plan) -> None
|
||||
Actually removes everything described by the cleanup plan.
|
||||
|
||||
enumerate_active() -> Sequence[ActiveAgent]
|
||||
Return every currently-running bottle on this backend, with
|
||||
enough metadata for callers (CLI `list active`, dashboard
|
||||
agents pane) to render a row.
|
||||
list_active() -> None
|
||||
Print every currently-running bottle on this backend to stderr.
|
||||
|
||||
Selection is driven by `--backend` on `start` or
|
||||
BOT_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the
|
||||
manifest does not carry a backend field; the host picks.
|
||||
Selection is driven by CLAUDE_BOTTLE_BACKEND (default "docker"). Per
|
||||
PRD 0003 the manifest does not carry a backend field; the host
|
||||
environment picks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..log import die
|
||||
from ..manifest import GitEntry, Manifest
|
||||
from ..supervise import SupervisePlan
|
||||
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
|
||||
|
||||
|
||||
@@ -72,57 +63,15 @@ class BottleSpec:
|
||||
@dataclass(frozen=True)
|
||||
class BottlePlan(ABC):
|
||||
"""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
|
||||
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:
|
||||
"""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)
|
||||
@@ -154,33 +103,11 @@ class ExecResult:
|
||||
stderr: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveAgent:
|
||||
"""One currently-running agent, as the CLI `list active` and
|
||||
dashboard agents pane render it. ("Agent" is the project's
|
||||
consistent name for the thing running inside a bottle — the
|
||||
bottle is the container, the agent is what runs in it.)
|
||||
|
||||
Fields are deliberately backend-neutral. `services` is the set
|
||||
of sidecar daemons currently up for this bottle (`pipelock`,
|
||||
`egress`, `git-gate`, `supervise`); the dashboard uses it to
|
||||
gate edit verbs. `backend_name` is the matching key in
|
||||
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
|
||||
list rendering to disambiguate and by the dashboard's
|
||||
re-attach path."""
|
||||
|
||||
backend_name: str
|
||||
slug: str
|
||||
agent_name: str # from metadata.json; "?" if missing
|
||||
started_at: str # ISO 8601 from metadata.json; "" if missing
|
||||
services: tuple[str, ...] # alphabetical
|
||||
|
||||
|
||||
class Bottle(ABC):
|
||||
"""Handle to a running bottle. Yielded by a backend's launch step.
|
||||
|
||||
`exec_agent` runs the selected agent CLI inside the bottle and
|
||||
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
|
||||
`exec_claude` runs `claude` inside the bottle and blocks until the
|
||||
session ends. `exec` runs a POSIX shell script inside the bottle
|
||||
and returns the captured result. `cp_in` copies a host path into
|
||||
the bottle. `close` is an idempotent alias for context-manager
|
||||
teardown.
|
||||
@@ -189,38 +116,15 @@ class Bottle(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def agent_argv(
|
||||
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."""
|
||||
...
|
||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
|
||||
|
||||
@abstractmethod
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
|
||||
|
||||
@abstractmethod
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
"""Run `script` as a POSIX shell script inside the bottle as
|
||||
`user` (default `node`, matching the agent image's USER
|
||||
directive) and return the captured stdout/stderr/returncode.
|
||||
The bottle's environment (including HTTPS_PROXY pointing at
|
||||
the pipelock sidecar) is inherited by the child. Non-zero
|
||||
exit does not raise — callers inspect `returncode`
|
||||
themselves.
|
||||
|
||||
Pass `user="root"` for shell-outs that need privileged file
|
||||
writes / package install — provisioning calls that need root
|
||||
bypass `Bottle.exec` and use the backend-specific raw
|
||||
machine-exec helper, but the tests have a legitimate use
|
||||
case for arbitrary-user runs."""
|
||||
def exec(self, script: str) -> ExecResult:
|
||||
"""Run `script` as a POSIX shell script inside the bottle and
|
||||
return the captured stdout/stderr/returncode. The bottle's
|
||||
environment (including HTTPS_PROXY pointing at the pipelock
|
||||
sidecar) is inherited by the child. Non-zero exit does not
|
||||
raise — callers inspect `returncode` themselves."""
|
||||
|
||||
@abstractmethod
|
||||
def cp_in(self, host_path: str, container_path: str) -> None: ...
|
||||
@@ -264,7 +168,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
self._validate_skills(agent.skills)
|
||||
self._validate_git_entries(bottle.git)
|
||||
self._validate_agent_provider_dockerfile(spec)
|
||||
|
||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||
"""Each named skill must be a directory under the host's
|
||||
@@ -288,20 +191,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
if not os.path.isfile(key):
|
||||
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
||||
|
||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
||||
dockerfile = bottle.agent_provider.dockerfile
|
||||
if not dockerfile:
|
||||
return
|
||||
path = Path(expand_tilde(dockerfile))
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
if not path.is_file():
|
||||
die(
|
||||
f"agent_provider.dockerfile for bottle "
|
||||
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||
"""Backend-specific plan resolution: image/container names,
|
||||
@@ -319,10 +208,10 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
backend-specific terms (Docker: resolved container name; fly:
|
||||
machine id). Returns the in-container prompt path if a prompt
|
||||
was provisioned, else None — the Bottle handle uses it to
|
||||
decide whether to add provider-specific prompt args to the agent's
|
||||
decide whether to add --append-system-prompt-file to claude's
|
||||
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
|
||||
is rebuilt before anything inside the agent makes a TLS call.
|
||||
Subclasses typically don't override this; they implement the
|
||||
@@ -335,9 +224,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
intercepted without per-tool reconfiguration."""
|
||||
self.provision_ca(plan, target)
|
||||
prompt_path = self.provision_prompt(plan, target)
|
||||
self.provision_provider_auth(plan, target)
|
||||
self.provision_skills(plan, target)
|
||||
self.provision_workspace(plan, target)
|
||||
self.provision_git(plan, target)
|
||||
self.provision_supervise(plan, target)
|
||||
return prompt_path
|
||||
@@ -351,28 +238,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
backend overrides to docker-cp the cert in and run
|
||||
`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
|
||||
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
||||
"""Copy the prompt file into the running bottle. Returns the
|
||||
in-container path iff the agent has a non-empty prompt;
|
||||
callers use the return value to decide whether to add
|
||||
provider-specific prompt args to the agent's argv."""
|
||||
--append-system-prompt-file to claude's argv."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_skills(self, plan: PlanT, target: str) -> None:
|
||||
"""Copy the agent's named skills from the host into the
|
||||
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
|
||||
def provision_git(self, plan: PlanT, target: str) -> None:
|
||||
"""Copy the host's cwd `.git` directory into the running
|
||||
@@ -395,29 +272,15 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""Remove everything described by the cleanup plan."""
|
||||
|
||||
@abstractmethod
|
||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||
"""Return every currently-running agent on this backend.
|
||||
Empty when none. Backend-specific: docker queries `docker
|
||||
compose ls`; smolmachines queries `smolvm machine ls --json`
|
||||
+ cross-references its bundle container."""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""Whether this backend's runtime prerequisites are satisfied
|
||||
on the current host. Docker → `docker` on PATH; smolmachines
|
||||
→ `smolvm` on PATH. Used by the cross-backend
|
||||
`enumerate_active_agents` / `cmd_cleanup` to skip backends
|
||||
the operator hasn't installed, so a docker-only host
|
||||
doesn't fail when `cli.py list active` walks past
|
||||
smolmachines."""
|
||||
def list_active(self) -> None:
|
||||
"""Print every currently-running bottle on this backend to
|
||||
stderr (name + status)."""
|
||||
|
||||
|
||||
# Import concrete backend classes AFTER the base types are defined, so
|
||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||
# via `from . import ...` without hitting a partially-initialized module.
|
||||
from .docker import DockerBottleBackend # noqa: E402
|
||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402
|
||||
|
||||
|
||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||
@@ -426,81 +289,26 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402
|
||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||
"docker": DockerBottleBackend(),
|
||||
"smolmachines": SmolmachinesBottleBackend(),
|
||||
}
|
||||
|
||||
|
||||
def get_bottle_backend(
|
||||
name: str | None = None,
|
||||
) -> BottleBackend[Any, Any]:
|
||||
"""Resolve the bottle backend.
|
||||
|
||||
`name` precedence:
|
||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
||||
2. BOT_BOTTLE_BACKEND env var
|
||||
3. default `docker`
|
||||
|
||||
Dies with a pointer at the known backends if the chosen name
|
||||
isn't implemented."""
|
||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "docker"
|
||||
if resolved not in _BACKENDS:
|
||||
known = ", ".join(sorted(_BACKENDS))
|
||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||
return _BACKENDS[resolved]
|
||||
|
||||
|
||||
def known_backend_names() -> tuple[str, ...]:
|
||||
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
||||
argparse (`--backend` choices) and the dashboard's backend
|
||||
picker."""
|
||||
return tuple(sorted(_BACKENDS))
|
||||
|
||||
|
||||
def has_backend(name: str) -> bool:
|
||||
"""Whether the named backend's runtime prerequisites are
|
||||
available on the current host. Cross-backend callers (list,
|
||||
cleanup) skip unavailable backends so a docker-only host
|
||||
doesn't fail when the smolmachines backend isn't installed,
|
||||
and vice versa.
|
||||
|
||||
Returns False for unknown names so callers can pass
|
||||
arbitrary input without separate validation."""
|
||||
def get_bottle_backend() -> BottleBackend[Any, Any]:
|
||||
"""Resolve the bottle backend for the active environment. Dies with
|
||||
a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an
|
||||
unimplemented one."""
|
||||
name = os.environ.get("CLAUDE_BOTTLE_BACKEND", "docker")
|
||||
if name not in _BACKENDS:
|
||||
return False
|
||||
return _BACKENDS[name].is_available()
|
||||
|
||||
|
||||
def enumerate_active_agents() -> list[ActiveAgent]:
|
||||
"""All currently-running agents, across every available
|
||||
backend. Used by CLI `list active` and the dashboard's agents
|
||||
pane so neither has to know which backends exist. Skips
|
||||
backends whose `is_available()` reports False.
|
||||
|
||||
Sorted by `(started_at, slug)` so the list is stable across
|
||||
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] = []
|
||||
for name in known_backend_names():
|
||||
if not has_backend(name):
|
||||
continue
|
||||
out.extend(_BACKENDS[name].enumerate_active())
|
||||
out.sort(key=lambda a: (a.started_at, a.slug))
|
||||
return out
|
||||
known = ", ".join(sorted(_BACKENDS))
|
||||
die(f"unknown CLAUDE_BOTTLE_BACKEND={name!r}; known backends: {known}")
|
||||
return _BACKENDS[name]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActiveAgent",
|
||||
"Bottle",
|
||||
"BottleBackend",
|
||||
"BottleCleanupPlan",
|
||||
"BottlePlan",
|
||||
"BottleSpec",
|
||||
"ExecResult",
|
||||
"enumerate_active_agents",
|
||||
"get_bottle_backend",
|
||||
"has_backend",
|
||||
"known_backend_names",
|
||||
]
|
||||
@@ -14,7 +14,7 @@ The bulk of the implementation lives in sibling modules:
|
||||
- backend: DockerBottleBackend façade wiring the above
|
||||
|
||||
This file only re-exports the public names so
|
||||
`from bot_bottle.backend.docker import DockerBottleBackend` keeps
|
||||
`from claude_bottle.backend.docker import DockerBottleBackend` keeps
|
||||
working.
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""DockerBottleBackend — the Docker implementation of BottleBackend.
|
||||
|
||||
This module is a thin façade. The real work lives in four siblings:
|
||||
This module is a thin façade. The real work lives in three siblings:
|
||||
|
||||
- prepare.py — host-side resolution into a DockerBottlePlan
|
||||
- launch.py — bring-up + teardown context manager
|
||||
- cleanup.py — orphan enumeration + removal
|
||||
- enumerate.py — active-agent listing
|
||||
- prepare.py — host-side resolution into a DockerBottlePlan
|
||||
- launch.py — bring-up + teardown context manager
|
||||
- cleanup.py — orphan enumeration, removal, and active listing
|
||||
|
||||
The base class's `prepare` template runs cross-backend host-side
|
||||
validation before calling `_resolve_plan` here.
|
||||
@@ -13,48 +12,60 @@ validation before calling `_resolve_plan` here.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
from typing import Generator
|
||||
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from .. import BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
from . import prepare as _prepare
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import DockerEgress
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import DockerPipelockProxy
|
||||
from .provision import ca as _ca
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import provider_auth as _provider_auth
|
||||
from .provision import skills as _skills
|
||||
from .provision import supervise as _supervise_prov
|
||||
from .supervise import DockerSupervise
|
||||
|
||||
|
||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
|
||||
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
|
||||
(default)."""
|
||||
|
||||
name = "docker"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""`docker` on PATH is sufficient; we don't probe `docker info`
|
||||
eagerly because the cross-backend enumerator runs this on
|
||||
every `list active` and we'd pay a subprocess per call. A
|
||||
broken daemon will surface its own error during prepare /
|
||||
launch."""
|
||||
return shutil.which("docker") is not None
|
||||
def __init__(self) -> None:
|
||||
self._proxy = DockerPipelockProxy()
|
||||
self._git_gate = DockerGitGate()
|
||||
self._egress = DockerEgress()
|
||||
self._supervise = DockerSupervise()
|
||||
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
|
||||
return _prepare.resolve_plan(
|
||||
spec,
|
||||
stage_dir=stage_dir,
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
egress=self._egress,
|
||||
supervise=self._supervise,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
with _launch.launch(
|
||||
plan,
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
egress=self._egress,
|
||||
supervise=self._supervise,
|
||||
provision=self.provision,
|
||||
) as bottle:
|
||||
yield bottle
|
||||
|
||||
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
@@ -63,9 +74,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
||||
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:
|
||||
_skills.provision_skills(plan, target)
|
||||
|
||||
@@ -81,5 +89,5 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
|
||||
_cleanup.cleanup(plan)
|
||||
|
||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||
return _enumerate.enumerate_active()
|
||||
def list_active(self) -> None:
|
||||
_cleanup.list_active()
|
||||
@@ -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
|
||||
|
||||
import subprocess
|
||||
from typing import Callable
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
|
||||
|
||||
@@ -17,47 +22,28 @@ class DockerBottle(Bottle):
|
||||
container: str,
|
||||
teardown: Callable[[], None],
|
||||
prompt_path_in_container: str | None,
|
||||
*,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: PromptMode = "append_file",
|
||||
):
|
||||
self.name = container
|
||||
self._teardown = teardown
|
||||
self._prompt_path = prompt_path_in_container
|
||||
self._agent_prompt_mode = agent_prompt_mode
|
||||
self.agent_command = agent_command
|
||||
self.agent_provider_template = (
|
||||
"codex" if agent_command == "codex" else "claude"
|
||||
)
|
||||
self._closed = False
|
||||
|
||||
def agent_argv(
|
||||
self, argv: list[str], *, tty: bool = True,
|
||||
) -> list[str]:
|
||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
full_argv = list(argv)
|
||||
full_argv.extend(
|
||||
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
|
||||
)
|
||||
if self._prompt_path:
|
||||
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
|
||||
cmd = ["docker", "exec"]
|
||||
if tty:
|
||||
cmd.append("-it")
|
||||
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||
return cmd
|
||||
cmd.extend([self.name, "claude", *full_argv])
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
return subprocess.run(
|
||||
self.agent_argv(argv, tty=tty), check=False,
|
||||
).returncode
|
||||
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
def exec(self, script: str) -> ExecResult:
|
||||
# Pipe via stdin to `sh -s` so the caller never has to worry
|
||||
# about quoting; the script source lands inside the container
|
||||
# without crossing argv. `-u <user>` overrides the image's
|
||||
# default USER — defaults to `node` which is already the
|
||||
# image's USER, so the explicit flag is a no-op there but
|
||||
# keeps the cross-backend contract uniform.
|
||||
# without crossing argv.
|
||||
result = subprocess.run(
|
||||
["docker", "exec", "-u", user, "-i", self.name, "sh", "-s"],
|
||||
["docker", "exec", "-i", self.name, "sh", "-s"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
+2
-2
@@ -5,12 +5,12 @@ compose ls` is the source of truth for what's running; the plan
|
||||
carries the projects to `compose down`, plus three fallback buckets
|
||||
for legacy / orphan resources:
|
||||
|
||||
- stray_containers: pre-compose `bot-bottle-*` containers not
|
||||
- stray_containers: pre-compose `claude-bottle-*` containers not
|
||||
attached to any compose project. Cleared via `docker rm -f`.
|
||||
- stray_networks: same idea for networks. Cleared via
|
||||
`docker network rm`.
|
||||
- orphan_state_dirs: per-bottle state dirs under
|
||||
~/.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`.
|
||||
|
||||
Compose-managed networks are removed by `compose down --volumes`,
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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
|
||||
|
||||
|
||||
@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()))
|
||||
|
||||
def _multi(label: str, values: list[str]) -> None:
|
||||
"""Print a label with N continuation-indented values. Used
|
||||
for env / skills / git-gate / egress where one item
|
||||
per line keeps the summary scannable."""
|
||||
if not values:
|
||||
info(f"{label}: (none)")
|
||||
return
|
||||
info(f"{label}: {values[0]}")
|
||||
indent = " " * (len(label) + 2)
|
||||
for v_ in values[1:]:
|
||||
info(f"{indent}{v_}")
|
||||
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
_multi("env ", env_names)
|
||||
_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:
|
||||
_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}")
|
||||
_multi(" egress ", egress_lines)
|
||||
print(file=sys.stderr)
|
||||
|
||||
+11
-16
@@ -6,15 +6,15 @@ helper saves before teardown, and the launch metadata that lets
|
||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
||||
lives at:
|
||||
|
||||
~/.bot-bottle/state/<identity>/
|
||||
~/.claude-bottle/state/<identity>/
|
||||
metadata.json — agent_name + cwd + started_at (for resume)
|
||||
Dockerfile — per-bottle override (absent → use repo's)
|
||||
transcript/ — last snapshotted agent state (best-effort)
|
||||
|
||||
When the per-bottle Dockerfile is present, the launch step builds
|
||||
the agent image with a per-bottle tag (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
|
||||
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.
|
||||
|
||||
Identity model:
|
||||
@@ -40,7 +40,7 @@ from ... import supervise as _supervise
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||
# Directory layout: ~/.claude-bottle/state/<identity>/...
|
||||
_STATE_SUBDIR = "state"
|
||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||
_TRANSCRIPT_SUBDIR = "transcript"
|
||||
@@ -91,7 +91,7 @@ def bottle_identity(agent_name: str) -> str:
|
||||
class BottleMetadata:
|
||||
"""Persistent record of how a bottle was launched, written at
|
||||
start time and read by `cli.py resume`. Lives at
|
||||
~/.bot-bottle/state/<identity>/metadata.json."""
|
||||
~/.claude-bottle/state/<identity>/metadata.json."""
|
||||
|
||||
identity: str
|
||||
agent_name: str
|
||||
@@ -105,10 +105,6 @@ class BottleMetadata:
|
||||
# written before chunk 3 (resume / inspect should fall back to
|
||||
# deriving from identity in that case).
|
||||
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:
|
||||
@@ -116,7 +112,7 @@ def metadata_path(identity: str) -> 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)."""
|
||||
path = metadata_path(metadata.identity)
|
||||
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)),
|
||||
started_at=str(raw.get("started_at", "")),
|
||||
compose_project=str(raw.get("compose_project", "")),
|
||||
backend=str(raw.get("backend", "")),
|
||||
)
|
||||
|
||||
|
||||
def bottle_state_dir(identity: str) -> Path:
|
||||
"""Per-bottle state directory on the host. Created lazily by the
|
||||
write helpers; readers tolerate its absence."""
|
||||
return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
|
||||
return _supervise.claude_bottle_root() / _STATE_SUBDIR / identity
|
||||
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
return f"bot-bottle-rebuilt-{identity}:latest"
|
||||
return f"claude-bottle-rebuilt-{identity}:latest"
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||
~/.claude-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||
survives state-dir cleanup."""
|
||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||
|
||||
+16
-12
@@ -5,11 +5,11 @@ On approval of a capability-block proposal, the dashboard calls
|
||||
apply_capability_change(slug, new_dockerfile) which:
|
||||
|
||||
1. Snapshots the agent's transcript dir to
|
||||
~/.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 —
|
||||
no upstream / no commits / no git repo all skip with a log).
|
||||
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.
|
||||
4. Force-removes the agent container + all sidecars + the
|
||||
per-bottle networks. Idempotent — missing resources are not
|
||||
@@ -43,7 +43,6 @@ from .bottle_state import (
|
||||
transcript_snapshot_dir,
|
||||
write_per_bottle_dockerfile,
|
||||
)
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
|
||||
|
||||
# Agent home inside the container (per the repo Dockerfile's
|
||||
@@ -53,9 +52,11 @@ _AGENT_HOME_IN_CONTAINER = "/home/node"
|
||||
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
|
||||
_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 /
|
||||
# the various sidecar modules). The agent container's name is the
|
||||
# slug with no infix; sidecars carry an infix like cred-proxy.
|
||||
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]:
|
||||
@@ -64,14 +65,17 @@ def _per_bottle_container_names(slug: str) -> list[str]:
|
||||
fine to include names that don't exist for a given bottle."""
|
||||
return [
|
||||
_agent_container_name(slug),
|
||||
sidecar_bundle_container_name(slug),
|
||||
f"claude-bottle-cred-proxy-{slug}",
|
||||
f"claude-bottle-pipelock-{slug}",
|
||||
f"claude-bottle-git-gate-{slug}",
|
||||
f"claude-bottle-supervise-{slug}",
|
||||
]
|
||||
|
||||
|
||||
def _per_bottle_network_names(slug: str) -> list[str]:
|
||||
return [
|
||||
f"bot-bottle-net-{slug}",
|
||||
f"bot-bottle-egress-{slug}",
|
||||
f"claude-bottle-net-{slug}",
|
||||
f"claude-bottle-egress-{slug}",
|
||||
]
|
||||
|
||||
|
||||
@@ -128,16 +132,16 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
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
|
||||
regardless of where this module is imported from."""
|
||||
# bot_bottle/backend/docker/capability_apply.py -> repo root
|
||||
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||
# claude_bottle/backend/docker/capability_apply.py -> repo root
|
||||
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||
|
||||
|
||||
def snapshot_transcript(slug: str) -> None:
|
||||
"""`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.
|
||||
The transcript is what `claude --resume` reads to pick up where
|
||||
the agent left off.
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Cleanup for the Docker bottle backend.
|
||||
"""Cleanup + active-listing for the Docker bottle backend.
|
||||
|
||||
PRD 0018 chunk 4: cleanup is centered on `docker compose ls`.
|
||||
Pre-compose code paths could leave bare containers / networks
|
||||
@@ -7,19 +7,19 @@ scan, just as a fallback bucket alongside the project list.
|
||||
|
||||
`prepare_cleanup` enumerates:
|
||||
|
||||
- Live compose projects whose name starts with `bot-bottle-`.
|
||||
- `bot-bottle-*` containers that aren't part of any compose
|
||||
- Live compose projects whose name starts with `claude-bottle-`.
|
||||
- `claude-bottle-*` containers that aren't part of any compose
|
||||
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
|
||||
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.
|
||||
|
||||
`cleanup` removes everything in the plan.
|
||||
|
||||
Active-agent enumeration lives in `backend/docker/enumerate.py`
|
||||
(mirror of `backend/smolmachines/enumerate.py`).
|
||||
`list_active` queries the same compose project namespace and prints
|
||||
each project's services for ad-hoc inspection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,7 +36,7 @@ from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
||||
|
||||
|
||||
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(
|
||||
["docker", "ps", "-a",
|
||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
||||
@@ -60,7 +60,7 @@ def _list_prefixed_containers() -> list[str]:
|
||||
|
||||
|
||||
def _list_prefixed_networks() -> list[str]:
|
||||
"""All bot-bottle-prefixed networks not currently attached
|
||||
"""All claude-bottle-prefixed networks not currently attached
|
||||
to a compose project. Compose-managed networks have a
|
||||
`com.docker.compose.project` label; bare ones (from pre-compose
|
||||
code paths) don't."""
|
||||
@@ -83,19 +83,12 @@ def _list_prefixed_networks() -> list[str]:
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _list_orphan_state_dirs(
|
||||
live_projects: set[str], protected_identities: set[str],
|
||||
) -> list[str]:
|
||||
def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]:
|
||||
"""State identities whose compose project isn't running and
|
||||
that don't have a `.preserve` marker. `.preserve` means the
|
||||
user (or an auto-preserve-on-crash) wants the state kept for
|
||||
`resume`.
|
||||
|
||||
`protected_identities` is the set of slugs that are live in
|
||||
ANY backend — used so this docker-side check doesn't reap a
|
||||
running smolmachines bottle's state dir (the layout is shared
|
||||
across both backends)."""
|
||||
state_root = _supervise.bot_bottle_root() / "state"
|
||||
`resume`."""
|
||||
state_root = _supervise.claude_bottle_root() / "state"
|
||||
if not state_root.is_dir():
|
||||
return []
|
||||
orphans: list[str] = []
|
||||
@@ -106,8 +99,6 @@ def _list_orphan_state_dirs(
|
||||
project = f"{COMPOSE_PROJECT_PREFIX}{identity}"
|
||||
if project in live_projects:
|
||||
continue
|
||||
if identity in protected_identities:
|
||||
continue
|
||||
if is_preserved(identity):
|
||||
continue
|
||||
orphans.append(identity)
|
||||
@@ -115,25 +106,15 @@ def _list_orphan_state_dirs(
|
||||
|
||||
|
||||
def prepare_cleanup() -> DockerBottleCleanupPlan:
|
||||
"""Enumerate everything cleanup will touch. No removals.
|
||||
|
||||
Pulls the union of live identities across backends via
|
||||
`enumerate_active_agents()` so the orphan-state-dir bucket
|
||||
doesn't include slugs whose smolmachines VM is still up."""
|
||||
"""Enumerate everything cleanup will touch. No removals."""
|
||||
docker_mod.require_docker()
|
||||
projects = list_compose_projects()
|
||||
project_set = set(projects)
|
||||
# Late import to avoid a circular at module-load time —
|
||||
# the backend package's __init__ imports this module.
|
||||
from .. import enumerate_active_agents
|
||||
protected = {a.slug for a in enumerate_active_agents()}
|
||||
return DockerBottleCleanupPlan(
|
||||
projects=tuple(projects),
|
||||
stray_containers=tuple(_list_prefixed_containers()),
|
||||
stray_networks=tuple(_list_prefixed_networks()),
|
||||
orphan_state_dirs=tuple(
|
||||
_list_orphan_state_dirs(project_set, protected),
|
||||
),
|
||||
orphan_state_dirs=tuple(_list_orphan_state_dirs(project_set)),
|
||||
)
|
||||
|
||||
|
||||
@@ -178,3 +159,26 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except OSError as e:
|
||||
warn(f"failed to remove {path}: {e}")
|
||||
|
||||
|
||||
def list_active() -> None:
|
||||
"""Print every active claude-bottle compose project + its
|
||||
services. Empty banner when there are none."""
|
||||
docker_mod.require_docker()
|
||||
active = list_compose_projects(include_stopped=False)
|
||||
if not active:
|
||||
info("no active claude-bottle compose projects")
|
||||
return
|
||||
print()
|
||||
for project in active:
|
||||
info(f"compose project: {project}")
|
||||
ps = subprocess.run(
|
||||
["docker", "compose", "-p", project, "ps", "--format",
|
||||
"{{.Service}}\t{{.Name}}\t{{.Status}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
for line in (ps.stdout or "").splitlines():
|
||||
service, _, rest = line.partition("\t")
|
||||
name, _, status = rest.partition("\t")
|
||||
info(f" {service:12s} {name} ({status})")
|
||||
print()
|
||||
@@ -20,11 +20,11 @@ SDK-call branching in `launch.py` today):
|
||||
|
||||
Naming:
|
||||
|
||||
- Compose project: `bot-bottle-<slug>`.
|
||||
- Compose project: `claude-bottle-<slug>`.
|
||||
- Service names (inside the file): `agent`, `pipelock`,
|
||||
`egress`, `git-gate`, `supervise`.
|
||||
- `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.
|
||||
- Network aliases preserve the current dial-by-shortname pattern
|
||||
for `egress` / `supervise`, and add the long container-name as
|
||||
@@ -49,9 +49,8 @@ from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
)
|
||||
from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
from ...pipelock import PIPELOCK_HOSTNAME
|
||||
from ...git_gate import git_gate_aggregate_extra_hosts
|
||||
from ...supervise import (
|
||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
||||
QUEUE_DIR_IN_CONTAINER,
|
||||
@@ -59,31 +58,40 @@ from ...supervise import (
|
||||
SUPERVISE_PORT,
|
||||
)
|
||||
from ...util import expand_tilde
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import (
|
||||
EGRESS_CA_IN_CONTAINER,
|
||||
EGRESS_DOCKERFILE,
|
||||
EGRESS_IMAGE,
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||
egress_container_name,
|
||||
)
|
||||
from .git_gate import (
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||
GIT_GATE_DOCKERFILE,
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_IMAGE,
|
||||
git_gate_container_name,
|
||||
)
|
||||
from .pipelock import (
|
||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||
PIPELOCK_IMAGE,
|
||||
PIPELOCK_PORT,
|
||||
pipelock_container_name,
|
||||
)
|
||||
from .sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
sidecar_bundle_container_name,
|
||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .supervise import (
|
||||
SUPERVISE_DOCKERFILE,
|
||||
SUPERVISE_IMAGE,
|
||||
supervise_container_name,
|
||||
)
|
||||
|
||||
|
||||
# Repo root, used as the build context for the bundle Dockerfile.
|
||||
# Repo root, used as the build context for sidecar Dockerfiles.
|
||||
# Same derivation as the per-sidecar lifecycle modules.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
@@ -98,11 +106,22 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
feed it a fully-resolved plan or get an incomplete compose
|
||||
spec back.
|
||||
"""
|
||||
project = f"bot-bottle-{plan.slug}"
|
||||
services: dict[str, Any] = {
|
||||
"sidecars": _sidecar_bundle_service(plan),
|
||||
"agent": _agent_service(plan),
|
||||
}
|
||||
project = f"claude-bottle-{plan.slug}"
|
||||
services: dict[str, Any] = {}
|
||||
|
||||
services["pipelock"] = _pipelock_service(plan)
|
||||
|
||||
if plan.git_gate_plan.upstreams:
|
||||
services["git-gate"] = _git_gate_service(plan)
|
||||
|
||||
if plan.egress_plan.routes:
|
||||
services["egress"] = _egress_service(plan)
|
||||
|
||||
if plan.supervise_plan is not None:
|
||||
services["supervise"] = _supervise_service(plan)
|
||||
|
||||
services["agent"] = _agent_service(plan)
|
||||
|
||||
return {
|
||||
"name": project,
|
||||
"services": services,
|
||||
@@ -140,125 +159,150 @@ def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str,
|
||||
}
|
||||
|
||||
|
||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""The `sidecars` service: one container per bottle, bundle
|
||||
image, all four daemons under a Python init supervisor.
|
||||
|
||||
Mechanics:
|
||||
|
||||
- Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS`
|
||||
env. pipelock is always present; egress / git-gate /
|
||||
supervise are conditional on the plan.
|
||||
- Volumes are the union of the four daemons' bind-mounts,
|
||||
preserving the same in-container paths so each daemon
|
||||
finds its config / hooks / CA where it expects.
|
||||
- Environment is the union of *daemon-private* env vars
|
||||
(EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc).
|
||||
HTTPS_PROXY is NOT propagated here — see the comment in
|
||||
egress_entrypoint.sh; setting it at the container level
|
||||
would route git-gate's git fetches through pipelock,
|
||||
which is wrong.
|
||||
- Network aliases register every legacy short/long
|
||||
hostname (pipelock, egress, git-gate, supervise plus
|
||||
their `bot-bottle-<service>-<slug>` long forms) so
|
||||
the agent's HTTPS_PROXY URL and any other inter-service
|
||||
reference resolves to the bundle.
|
||||
"""
|
||||
daemons: list[str] = ["egress", "pipelock"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
daemons.append("git-gate")
|
||||
if plan.supervise_plan is not None:
|
||||
daemons.append("supervise")
|
||||
|
||||
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
||||
volumes: list[dict[str, Any]] = []
|
||||
|
||||
# --- pipelock ----------------------------------------------------
|
||||
def _pipelock_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Pipelock sidecar. Pinned-digest image (no build). The
|
||||
rendered YAML config + CA cert + key bind-mount in from the
|
||||
paths the prepare step laid down on plan.proxy_plan."""
|
||||
pp = plan.proxy_plan
|
||||
volumes += [
|
||||
_bind(pp.yaml_path, "/etc/pipelock.yaml"),
|
||||
_bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER),
|
||||
_bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER),
|
||||
]
|
||||
name = pipelock_container_name(plan.slug)
|
||||
return {
|
||||
"image": PIPELOCK_IMAGE,
|
||||
"container_name": name,
|
||||
"command": [
|
||||
"run",
|
||||
"--config", "/etc/pipelock.yaml",
|
||||
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
|
||||
],
|
||||
"networks": {
|
||||
"internal": {"aliases": [name]},
|
||||
"egress": None,
|
||||
},
|
||||
"volumes": [
|
||||
_bind(pp.yaml_path, "/etc/pipelock.yaml"),
|
||||
_bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER),
|
||||
_bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER),
|
||||
],
|
||||
}
|
||||
|
||||
# --- egress (always part of the bundle; the EGRESS_UPSTREAM_*
|
||||
# env vars + ca bind-mounts are needed iff routes exist; when
|
||||
# the bottle has no routes the egress daemon falls back to its
|
||||
# `regular@9099` mode and is unused) -----------------------------
|
||||
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 += [
|
||||
_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER),
|
||||
_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER),
|
||||
_bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER),
|
||||
]
|
||||
for token_env in sorted(ep.token_env_map.keys()):
|
||||
env.append(token_env)
|
||||
|
||||
# --- git-gate ----------------------------------------------------
|
||||
def _git_gate_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Git-gate sidecar. Built from Dockerfile.git-gate. Entrypoint
|
||||
+ pre-receive hook + access-hook bind-mount from the stage
|
||||
paths the prepare step wrote. Per-upstream identity files
|
||||
bind-mount from the user's ssh-key location after `~`
|
||||
expansion. Per-upstream known_hosts files come in via chunk 2 —
|
||||
the GitGatePlan doesn't carry those host paths yet (they're
|
||||
currently materialized at start time by DockerGitGate.start).
|
||||
"""
|
||||
gp = plan.git_gate_plan
|
||||
if gp.upstreams:
|
||||
volumes += [
|
||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
||||
]
|
||||
for u in gp.upstreams:
|
||||
keypath = expand_tilde(u.identity_file)
|
||||
volumes.append(_bind(
|
||||
keypath,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||
))
|
||||
if u.known_hosts_file:
|
||||
volumes.append(_bind(
|
||||
u.known_hosts_file,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||
))
|
||||
name = git_gate_container_name(plan.slug)
|
||||
|
||||
# --- supervise ---------------------------------------------------
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
volumes.append({
|
||||
"type": "bind",
|
||||
"source": str(sp.queue_dir),
|
||||
"target": QUEUE_DIR_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
|
||||
# Internal-network aliases: the agent reaches each daemon through
|
||||
# its short name (pipelock / egress / git-gate / supervise) which
|
||||
# the bundle answers as if it were the daemon itself.
|
||||
internal_aliases = [
|
||||
PIPELOCK_HOSTNAME,
|
||||
EGRESS_HOSTNAME,
|
||||
volumes: list[dict[str, Any]] = [
|
||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
||||
]
|
||||
if gp.upstreams:
|
||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||
if sp is not None:
|
||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
||||
for u in gp.upstreams:
|
||||
keypath = expand_tilde(u.identity_file)
|
||||
volumes.append(_bind(
|
||||
keypath,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||
))
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": SIDECAR_BUNDLE_IMAGE,
|
||||
"image": GIT_GATE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
"dockerfile": GIT_GATE_DOCKERFILE,
|
||||
},
|
||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||
"container_name": name,
|
||||
"networks": {
|
||||
"internal": {"aliases": internal_aliases},
|
||||
"internal": {"aliases": [name]},
|
||||
"egress": None,
|
||||
},
|
||||
"volumes": volumes,
|
||||
}
|
||||
extra_hosts = git_gate_aggregate_extra_hosts(gp.upstreams)
|
||||
if extra_hosts:
|
||||
service["extra_hosts"] = [
|
||||
f"{host}:{ip}" for host, ip in sorted(extra_hosts.items())
|
||||
]
|
||||
return service
|
||||
|
||||
|
||||
def _egress_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Egress sidecar. Built from Dockerfile.egress. Routes
|
||||
through pipelock on its upstream leg via `EGRESS_UPSTREAM_PROXY` +
|
||||
`EGRESS_UPSTREAM_CA`. One env-list entry per upstream-token slot
|
||||
(bare NAME inherits from the compose-up process env, so secret
|
||||
values stay off argv and out of the compose file). routes.yaml +
|
||||
mitmproxy CA + pipelock CA bind-mount from the stage paths."""
|
||||
ep = plan.egress_plan
|
||||
name = egress_container_name(plan.slug)
|
||||
|
||||
env: list[str] = [
|
||||
f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}",
|
||||
f"HTTPS_PROXY={ep.pipelock_proxy_url}",
|
||||
f"HTTP_PROXY={ep.pipelock_proxy_url}",
|
||||
"NO_PROXY=localhost,127.0.0.1",
|
||||
f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
|
||||
]
|
||||
for token_env in sorted(ep.token_env_map.keys()):
|
||||
env.append(token_env)
|
||||
|
||||
return {
|
||||
"image": EGRESS_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": EGRESS_DOCKERFILE,
|
||||
},
|
||||
"container_name": name,
|
||||
"networks": {
|
||||
"internal": {"aliases": [EGRESS_HOSTNAME]},
|
||||
"egress": None,
|
||||
},
|
||||
"environment": env,
|
||||
"volumes": volumes,
|
||||
"volumes": [
|
||||
_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER),
|
||||
_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER),
|
||||
_bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER),
|
||||
],
|
||||
"depends_on": ["pipelock"],
|
||||
}
|
||||
|
||||
|
||||
def _supervise_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Supervise sidecar. Internal network only — no upstream calls.
|
||||
Queue dir bind-mounts read-write so the sidecar can append audit
|
||||
events and the host-side capability handlers can drop new
|
||||
proposals into it."""
|
||||
sp = plan.supervise_plan
|
||||
assert sp is not None
|
||||
name = supervise_container_name(plan.slug)
|
||||
return {
|
||||
"image": SUPERVISE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SUPERVISE_DOCKERFILE,
|
||||
},
|
||||
"container_name": name,
|
||||
"networks": {
|
||||
"internal": {"aliases": [SUPERVISE_HOSTNAME]},
|
||||
},
|
||||
"environment": [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"type": "bind",
|
||||
"source": str(sp.queue_dir),
|
||||
"target": QUEUE_DIR_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
@@ -281,8 +325,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
f"SSL_CERT_FILE={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):
|
||||
# bare name → inherits from compose-up process env, value
|
||||
# never lands on argv or in the compose file.
|
||||
@@ -310,10 +352,14 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
if volumes:
|
||||
service["volumes"] = volumes
|
||||
|
||||
# The init supervisor inside the bundle owns intra-bundle
|
||||
# daemon ordering, so the agent only waits for the bundle
|
||||
# container itself.
|
||||
service["depends_on"] = ["sidecars"]
|
||||
depends_on = ["pipelock"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
depends_on.append("git-gate")
|
||||
if plan.egress_plan.routes:
|
||||
depends_on.append("egress")
|
||||
if plan.supervise_plan is not None:
|
||||
depends_on.append("supervise")
|
||||
service["depends_on"] = depends_on
|
||||
|
||||
return service
|
||||
|
||||
@@ -326,7 +372,7 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
||||
if plan.egress_plan.routes:
|
||||
from .egress import EGRESS_PORT
|
||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
||||
return f"http://{PIPELOCK_HOSTNAME}:{PIPELOCK_PORT}"
|
||||
return f"http://{pipelock_container_name(plan.slug)}:{PIPELOCK_PORT}"
|
||||
|
||||
|
||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||
@@ -353,7 +399,7 @@ COMPOSE_FILE_NAME = "docker-compose.yml"
|
||||
COMPOSE_LOG_NAME = "compose.log"
|
||||
|
||||
|
||||
COMPOSE_PROJECT_PREFIX = "bot-bottle-"
|
||||
COMPOSE_PROJECT_PREFIX = "claude-bottle-"
|
||||
|
||||
|
||||
def compose_project_name(slug: str) -> str:
|
||||
@@ -373,20 +419,15 @@ def slug_from_compose_project(project: str) -> str:
|
||||
return project[len(COMPOSE_PROJECT_PREFIX):]
|
||||
|
||||
|
||||
def list_compose_projects(
|
||||
*, include_stopped: bool = True, warn_on_error: bool = True,
|
||||
) -> list[str]:
|
||||
"""All compose project names starting with `bot-bottle-`.
|
||||
def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
|
||||
"""All compose project names starting with `claude-bottle-`.
|
||||
`include_stopped=True` (default) runs `docker compose ls --all`
|
||||
so exited projects appear too; pass False to get only projects
|
||||
with at least one running container.
|
||||
|
||||
Returns [] on docker daemon errors or malformed output rather
|
||||
than raising — callers should treat the empty list as "no
|
||||
projects discoverable", not "no projects exist". `warn_on_error`
|
||||
stays true for explicit operator commands like cleanup, but active
|
||||
discovery paths set it false so dashboard refreshes don't spam
|
||||
stderr while Docker Desktop is stopped."""
|
||||
projects discoverable", not "no projects exist"."""
|
||||
argv = ["docker", "compose", "ls", "--format", "json"]
|
||||
if include_stopped:
|
||||
argv.insert(3, "--all")
|
||||
@@ -399,14 +440,12 @@ def list_compose_projects(
|
||||
# error from the caller's POV: no projects discoverable.
|
||||
return []
|
||||
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 []
|
||||
try:
|
||||
projects = json.loads(result.stdout or "[]")
|
||||
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 []
|
||||
names: list[str] = []
|
||||
for p in projects:
|
||||
@@ -418,19 +457,14 @@ def list_compose_projects(
|
||||
return sorted(set(names))
|
||||
|
||||
|
||||
def list_active_slugs(
|
||||
*, include_stopped: bool = False, warn_on_error: bool = True,
|
||||
) -> list[str]:
|
||||
def list_active_slugs(*, include_stopped: bool = False) -> list[str]:
|
||||
"""Slugs (project name minus prefix) of currently-running
|
||||
bottles. Used by the dashboard's operator-edit verbs to choose
|
||||
a bottle to apply a config edit to."""
|
||||
return sorted(
|
||||
slug for slug in (
|
||||
slug_from_compose_project(p)
|
||||
for p in list_compose_projects(
|
||||
include_stopped=include_stopped,
|
||||
warn_on_error=warn_on_error,
|
||||
)
|
||||
for p in list_compose_projects(include_stopped=include_stopped)
|
||||
) if slug
|
||||
)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""DockerEgress — the Docker-specific lifecycle for the
|
||||
per-bottle egress sidecar (PRD 0017). Inherits the platform-
|
||||
agnostic prepare step (route lift + routes.yaml render + token-env
|
||||
map derivation) from `Egress`.
|
||||
|
||||
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
|
||||
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
|
||||
block remediation flow (PRD 0014)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
Egress,
|
||||
EgressPlan,
|
||||
egress_resolve_token_values,
|
||||
)
|
||||
from ...log import die, info, warn
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
|
||||
|
||||
EGRESS_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_EGRESS_IMAGE",
|
||||
"claude-bottle-egress:latest",
|
||||
)
|
||||
|
||||
EGRESS_DOCKERFILE = "Dockerfile.egress"
|
||||
|
||||
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
|
||||
# resolves to `http://egress:<port>`.
|
||||
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
|
||||
|
||||
# In-container path for mitmproxy's CA. The format is a single PEM
|
||||
# file holding BOTH the cert and the private key, concatenated. The
|
||||
# upstream-trust CA (pipelock's, so egress trusts the upstream
|
||||
# leg) is a separate file because pipelock keeps a different CA on
|
||||
# its end.
|
||||
EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER = (
|
||||
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
|
||||
)
|
||||
|
||||
# Repo root, for `docker build` context. Resolved from this file's
|
||||
# location: claude_bottle/backend/docker/egress.py → repo root.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def egress_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-egress-{slug}"
|
||||
|
||||
|
||||
def egress_url() -> str:
|
||||
"""Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable
|
||||
across bottles because the sidecar attaches `--network-alias
|
||||
egress` on the internal network; the container name (which
|
||||
carries the slug) is not referenced by agent-side config."""
|
||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
||||
|
||||
|
||||
def build_egress_image() -> None:
|
||||
"""Build the egress image from `Dockerfile.egress`.
|
||||
Called by `DockerEgress.start`; exposed at module level so
|
||||
integration tests can build it without running the full launch
|
||||
pipeline."""
|
||||
docker_mod.build_image(
|
||||
EGRESS_IMAGE, _REPO_DIR, dockerfile=EGRESS_DOCKERFILE,
|
||||
)
|
||||
|
||||
|
||||
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
||||
|
||||
Returns `(mitmproxy_pem, cert_only_pem)`:
|
||||
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
||||
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
|
||||
- `cert_only_pem` is the cert alone — installed into the agent's
|
||||
trust store by `provision_ca` so the agent trusts the bumped
|
||||
CONNECT cert egress presents.
|
||||
|
||||
Why openssl req (not the pipelock binary's `tls init`):
|
||||
pipelock's CA generator stamps a non-standard `Subject Key
|
||||
Identifier` on the CA (random rather than SHA-1 of the pubkey).
|
||||
mitmproxy computes the `Authority Key Identifier` on each leaf
|
||||
it mints as SHA-1(issuer's pubkey). openssl's chain validator
|
||||
uses the leaf's AKI to find the issuer cert by SKI; pipelock's
|
||||
SKI doesn't match → openssl reports "unable to get local issuer
|
||||
certificate" even though the CA is right there in the trust
|
||||
store. openssl req's `subjectKeyIdentifier=hash` extension uses
|
||||
SHA-1(pubkey), matching mitmproxy's computation.
|
||||
|
||||
Both files live under `<stage_dir>/egress-ca/` (mode 644 —
|
||||
`docker cp` preserves the mode into the container, where the
|
||||
mitmproxy user (uid 1000) reads them; the host stage_dir is
|
||||
mode 700 so the private key isn't world-exposed)."""
|
||||
work = stage_dir / "egress-ca"
|
||||
work.mkdir(exist_ok=True)
|
||||
key_path = work / "ca-key.pem"
|
||||
cert_path = work / "ca.pem"
|
||||
cnf_path = work / "ca.cnf"
|
||||
|
||||
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
|
||||
# config matches RSA CAs without surprise), and openssl req's
|
||||
# default behavior here is exactly what we want.
|
||||
keygen = subprocess.run(
|
||||
["openssl", "genrsa", "-out", str(key_path), "2048"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if keygen.returncode != 0:
|
||||
die(f"egress ca keygen failed: {keygen.stderr.strip()}")
|
||||
# Standalone private key — never docker-cp'd, never bind-mounted
|
||||
# (mitmproxy reads the cert+key concat below). Lock to owner-
|
||||
# only so it doesn't sit at the default umask on disk.
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
|
||||
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
|
||||
# leaves it later mints. Without this, chain validation breaks
|
||||
# despite the CA being present in the trust store.
|
||||
cnf_path.write_text(
|
||||
"[req]\n"
|
||||
"distinguished_name = req_dn\n"
|
||||
"prompt = no\n"
|
||||
"x509_extensions = v3_ca\n"
|
||||
"\n"
|
||||
"[req_dn]\n"
|
||||
"O = claude-bottle\n"
|
||||
"CN = claude-bottle egress CA\n"
|
||||
"\n"
|
||||
"[v3_ca]\n"
|
||||
"basicConstraints = critical, CA:TRUE\n"
|
||||
"keyUsage = critical, keyCertSign, cRLSign\n"
|
||||
"subjectKeyIdentifier = hash\n"
|
||||
)
|
||||
cnf_path.chmod(0o644)
|
||||
|
||||
req = subprocess.run(
|
||||
["openssl", "req", "-x509", "-new", "-nodes",
|
||||
"-key", str(key_path),
|
||||
"-sha256", "-days", "365",
|
||||
"-config", str(cnf_path),
|
||||
"-out", str(cert_path)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if req.returncode != 0:
|
||||
die(f"egress ca cert generation failed: {req.stderr.strip()}")
|
||||
|
||||
cert_path.chmod(0o644)
|
||||
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||
# This file IS bind-mounted into the egress container (chunk 3+),
|
||||
# where mitmproxy runs as uid 1000 — so the host file has to be
|
||||
# world-readable for the container's user to read it through the
|
||||
# mount. Owner-only mode on the parent dir (state/<slug>/, under
|
||||
# ~/.claude-bottle which inherits ~'s 0o700) is what actually
|
||||
# restricts who can reach this file on the host.
|
||||
mitm = work / "mitmproxy-ca.pem"
|
||||
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||
mitm.chmod(0o644)
|
||||
return (mitm, cert_path)
|
||||
|
||||
|
||||
class DockerEgress(Egress):
|
||||
"""Brings the egress sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: EgressPlan) -> str:
|
||||
"""Boot the egress sidecar:
|
||||
1. Resolve every host TokenRef env var into a concrete
|
||||
value. Fails early if any are unset.
|
||||
2. Build the egress image (no-op when cache is hot).
|
||||
3. `docker create` on the internal network with
|
||||
`--network-alias egress`, the `HTTPS_PROXY=pipelock`
|
||||
env (so the upstream leg traverses pipelock), the
|
||||
`EGRESS_UPSTREAM_CA` env pointing at the in-container
|
||||
pipelock-CA path (so mitmproxy trusts pipelock's MITM),
|
||||
and one `-e EGRESS_TOKEN_N` flag per token slot.
|
||||
Secret values arrive via subprocess env, never argv.
|
||||
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
|
||||
concat), and pipelock CA (cert only) into the container.
|
||||
5. Attach to the per-agent egress network so the proxy can
|
||||
reach pipelock.
|
||||
6. `docker start`.
|
||||
Returns the container name (the target passed to `.stop`)."""
|
||||
if not plan.routes:
|
||||
die("DockerEgress.start called with no routes; caller should skip")
|
||||
if not plan.internal_network or not plan.egress_network:
|
||||
die(
|
||||
"DockerEgress.start: internal_network / egress_network must be "
|
||||
"populated on the plan before start"
|
||||
)
|
||||
if not plan.routes_path.is_file():
|
||||
die(
|
||||
f"egress routes file missing at {plan.routes_path}; "
|
||||
f"Egress.prepare must run first"
|
||||
)
|
||||
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
|
||||
die(
|
||||
f"DockerEgress.start: mitmproxy CA missing at "
|
||||
f"{plan.mitmproxy_ca_host_path}; egress_tls_init must run first"
|
||||
)
|
||||
# pipelock CA + upstream proxy URL: both must be present (we
|
||||
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
|
||||
# upstream leg) or both absent (egress goes direct, for
|
||||
# standalone integration tests that don't bring pipelock up).
|
||||
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
|
||||
if route_via_pipelock:
|
||||
if not plan.pipelock_proxy_url:
|
||||
die(
|
||||
"DockerEgress.start: pipelock_ca_host_path is set but "
|
||||
"pipelock_proxy_url is empty; populate both or neither."
|
||||
)
|
||||
if not plan.pipelock_ca_host_path.is_file():
|
||||
die(
|
||||
f"DockerEgress.start: pipelock CA missing at "
|
||||
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
|
||||
)
|
||||
|
||||
# Resolve host env vars into concrete values. Must happen at
|
||||
# start time (not prepare) — the values flow into the sidecar's
|
||||
# environ via subprocess env. The plan never holds them.
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.token_env_map, dict(os.environ),
|
||||
)
|
||||
|
||||
build_egress_image()
|
||||
|
||||
name = egress_container_name(plan.slug)
|
||||
info(f"starting egress sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
"--network-alias", EGRESS_HOSTNAME,
|
||||
]
|
||||
if route_via_pipelock:
|
||||
# Route egress's outbound traffic through pipelock
|
||||
# so the egress allowlist + DLP body scanner apply to
|
||||
# the egress → upstream leg. Pipelock MITMs each
|
||||
# handshake with its per-bottle CA, which is docker-cp'd
|
||||
# in below and pointed to via the EGRESS_UPSTREAM_CA
|
||||
# env (entrypoint conditionally adds the matching --set
|
||||
# flag).
|
||||
#
|
||||
# EGRESS_UPSTREAM_PROXY is the mechanism: mitmproxy
|
||||
# does NOT honor HTTPS_PROXY env vars on its outbound
|
||||
# side (it's a proxy server, not a client). The
|
||||
# entrypoint reads this env and switches mitmdump to
|
||||
# `--mode upstream:<URL>` so all post-MITM traffic
|
||||
# CONNECTs to pipelock instead of going direct. The
|
||||
# HTTPS/HTTP_PROXY env vars below are kept for any
|
||||
# bundled client libraries (mitmproxy plugin requests,
|
||||
# etc.) that might honor them — harmless if ignored.
|
||||
create_args.extend([
|
||||
"-e", f"EGRESS_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||
"-e", f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
|
||||
])
|
||||
# One -e flag per token slot; values arrive via subprocess env.
|
||||
# docker create with `-e NAME` (no =VALUE) reads NAME from the
|
||||
# current process env at create time. We pass `env=child_env`
|
||||
# to subprocess.run so the value comes from token_values, not
|
||||
# the host's os.environ directly — keeps the resolver in one
|
||||
# place and lets egress_resolve_token_values surface
|
||||
# missing-env errors with a clear hint.
|
||||
for token_env in sorted(plan.token_env_map.keys()):
|
||||
create_args.extend(["-e", token_env])
|
||||
create_args.append(EGRESS_IMAGE)
|
||||
|
||||
child_env: dict[str, str] = {**os.environ, **token_values}
|
||||
|
||||
create_result = subprocess.run(
|
||||
create_args, capture_output=True, text=True, env=child_env, check=False,
|
||||
)
|
||||
if create_result.returncode != 0:
|
||||
die(
|
||||
f"failed to create egress sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
# routes.yaml also lands inside the container; bump to 644
|
||||
# for the same reason as the CAs — mitmproxy user (uid 1000)
|
||||
# has to read it. Host stage_dir is mode 700 so the file
|
||||
# isn't actually exposed to other host users.
|
||||
plan.routes_path.chmod(0o644)
|
||||
# Pipelock CA: pipelock itself runs as root so its in-pipelock
|
||||
# copy doesn't care about mode, but egress's mitmproxy
|
||||
# user does. Bump on the host so docker cp into egress
|
||||
# carries world-readable.
|
||||
if route_via_pipelock:
|
||||
plan.pipelock_ca_host_path.chmod(0o644)
|
||||
cps: list[tuple[Path, str, str]] = [
|
||||
(plan.routes_path, EGRESS_ROUTES_IN_CONTAINER, "routes.yaml"),
|
||||
(plan.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER, "mitmproxy CA"),
|
||||
]
|
||||
if route_via_pipelock:
|
||||
cps.append((
|
||||
plan.pipelock_ca_host_path,
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||
"pipelock CA",
|
||||
))
|
||||
for src, dst, label in cps:
|
||||
cp_result = subprocess.run(
|
||||
["docker", "cp", str(src), f"{name}:{dst}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if cp_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to copy {label} into {name}: "
|
||||
f"{cp_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
connect_result = subprocess.run(
|
||||
["docker", "network", "connect", plan.egress_network, name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if connect_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to attach egress sidecar {name} to egress network "
|
||||
f"{plan.egress_network}: {connect_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
start_result = subprocess.run(
|
||||
["docker", "start", name], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if start_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to start egress sidecar {name}: "
|
||||
f"{start_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
def stop(self, target: str) -> None:
|
||||
"""Idempotent: missing container is success. `target` is the
|
||||
container name returned by `.start`."""
|
||||
if subprocess.run(
|
||||
["docker", "inspect", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0:
|
||||
if subprocess.run(
|
||||
["docker", "rm", "-f", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove egress sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
+3
-3
@@ -31,7 +31,7 @@ from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||
from ...egress_addon_core import load_routes
|
||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
from .bottle_state import egress_state_dir
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
from .egress import egress_container_name
|
||||
from .pipelock_apply import (
|
||||
PipelockApplyError,
|
||||
apply_allowlist_change,
|
||||
@@ -81,7 +81,7 @@ def fetch_current_routes(slug: str) -> str:
|
||||
for `slug`. Returns the file content as a string. Raises
|
||||
EgressApplyError if the sidecar isn't reachable or the read
|
||||
fails."""
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
container = egress_container_name(slug)
|
||||
r = subprocess.run(
|
||||
["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
|
||||
capture_output=True, text=True, check=False,
|
||||
@@ -185,7 +185,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||||
|
||||
Returns (before, after) where `after` == `new_content`. Raises
|
||||
EgressApplyError on any step."""
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
container = egress_container_name(slug)
|
||||
before = fetch_current_routes(slug)
|
||||
validate_routes_content(new_content)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""DockerGitGate — the Docker-specific lifecycle for the per-agent
|
||||
git-gate sidecar (PRD 0008). Inherits the platform-agnostic prepare
|
||||
step (upstream lift + entrypoint/hook render) from `GitGate`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...git_gate import (
|
||||
GitGate,
|
||||
GitGatePlan,
|
||||
git_gate_aggregate_extra_hosts,
|
||||
git_gate_known_hosts_line,
|
||||
)
|
||||
from ...log import die, info, warn
|
||||
from ...util import expand_tilde
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
GIT_GATE_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_GIT_GATE_IMAGE",
|
||||
"claude-bottle-git-gate:latest",
|
||||
)
|
||||
|
||||
GIT_GATE_DOCKERFILE = "Dockerfile.git-gate"
|
||||
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
||||
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
||||
|
||||
# git daemon's default listening port. Surfaced as a constant because
|
||||
# integration tests probe the gate on it.
|
||||
GIT_GATE_PORT = 9418
|
||||
|
||||
# Repo root, for `docker build` context. Resolved from this file's
|
||||
# location: claude_bottle/backend/docker/git_gate.py → repo root.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def git_gate_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-git-gate-{slug}"
|
||||
|
||||
|
||||
def git_gate_host(slug: str) -> str:
|
||||
"""The hostname the agent's git client should connect to (same as
|
||||
the container name — Docker's embedded DNS resolves it on the
|
||||
`--internal` network)."""
|
||||
return git_gate_container_name(slug)
|
||||
|
||||
|
||||
def build_git_gate_image() -> None:
|
||||
"""Build the git-gate image from `Dockerfile.git-gate`. Called by
|
||||
`DockerGitGate.start`; exposed at module level so integration
|
||||
tests can build it without running the full launch pipeline."""
|
||||
docker_mod.build_image(GIT_GATE_IMAGE, _REPO_DIR, dockerfile=GIT_GATE_DOCKERFILE)
|
||||
|
||||
|
||||
class DockerGitGate(GitGate):
|
||||
"""Brings the git-gate sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: GitGatePlan) -> str:
|
||||
"""Boot the gate sidecar:
|
||||
1. Build the gate image (no-op when cache is hot).
|
||||
2. `docker create` on the internal network with the canonical
|
||||
name; the image's ENTRYPOINT runs the cp'd entrypoint
|
||||
script at start time.
|
||||
3. `docker cp` the entrypoint, the shared pre-receive hook,
|
||||
and each upstream's identity + known_hosts into the
|
||||
container.
|
||||
4. Attach to the per-agent egress network so the gate can
|
||||
reach the real upstream.
|
||||
5. `docker start`.
|
||||
Returns the container name (the target passed to `.stop`)."""
|
||||
if not plan.upstreams:
|
||||
die("DockerGitGate.start called with no upstreams; caller should skip")
|
||||
if not plan.internal_network or not plan.egress_network:
|
||||
die(
|
||||
"DockerGitGate.start: internal_network / egress_network must be "
|
||||
"populated on the plan before start"
|
||||
)
|
||||
if not plan.entrypoint_script.is_file():
|
||||
die(
|
||||
f"git-gate entrypoint missing at {plan.entrypoint_script}; "
|
||||
f"GitGate.prepare must run first"
|
||||
)
|
||||
if not plan.hook_script.is_file():
|
||||
die(
|
||||
f"git-gate hook missing at {plan.hook_script}; "
|
||||
f"GitGate.prepare must run first"
|
||||
)
|
||||
if not plan.access_hook_script.is_file():
|
||||
die(
|
||||
f"git-gate access-hook missing at {plan.access_hook_script}; "
|
||||
f"GitGate.prepare must run first"
|
||||
)
|
||||
|
||||
build_git_gate_image()
|
||||
|
||||
name = git_gate_container_name(plan.slug)
|
||||
info(f"starting git-gate sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
]
|
||||
for host, ip in git_gate_aggregate_extra_hosts(plan.upstreams).items():
|
||||
create_args.extend(["--add-host", f"{host}:{ip}"])
|
||||
create_args.append(GIT_GATE_IMAGE)
|
||||
create_result = subprocess.run(
|
||||
create_args, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if create_result.returncode != 0:
|
||||
die(
|
||||
f"failed to create git-gate sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
# Order matters: entrypoint + hook first so they're present
|
||||
# when docker start fires. Per-upstream creds afterwards.
|
||||
stage_dir = plan.entrypoint_script.parent
|
||||
cps: list[tuple[str, str, str]] = [
|
||||
(str(plan.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, "entrypoint"),
|
||||
(str(plan.hook_script), GIT_GATE_HOOK_IN_CONTAINER, "pre-receive hook"),
|
||||
(str(plan.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, "access-hook"),
|
||||
]
|
||||
for u in plan.upstreams:
|
||||
keypath = expand_tilde(u.identity_file)
|
||||
cps.append((
|
||||
keypath,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||
f"upstream key for '{u.name}'",
|
||||
))
|
||||
if u.known_host_key:
|
||||
hosts_path = stage_dir / f"git_gate_known_hosts_{u.name}"
|
||||
hosts_path.write_text(
|
||||
git_gate_known_hosts_line(
|
||||
u.upstream_host, u.upstream_port, u.known_host_key
|
||||
)
|
||||
)
|
||||
hosts_path.chmod(0o600)
|
||||
cps.append((
|
||||
str(hosts_path),
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||
f"upstream known_hosts for '{u.name}'",
|
||||
))
|
||||
|
||||
for src, dst, label in cps:
|
||||
cp_result = subprocess.run(
|
||||
["docker", "cp", src, f"{name}:{dst}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if cp_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to copy {label} into {name}: "
|
||||
f"{cp_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
connect_result = subprocess.run(
|
||||
["docker", "network", "connect", plan.egress_network, name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if connect_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to attach git-gate sidecar {name} to egress network "
|
||||
f"{plan.egress_network}: {connect_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
start_result = subprocess.run(
|
||||
["docker", "start", name], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if start_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to start git-gate sidecar {name}: "
|
||||
f"{start_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
def stop(self, target: str) -> None:
|
||||
"""Idempotent: missing container is success. `target` is the
|
||||
container name returned by `.start`."""
|
||||
if subprocess.run(
|
||||
["docker", "inspect", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0:
|
||||
if subprocess.run(
|
||||
["docker", "rm", "-f", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove git-gate sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ The flow is:
|
||||
entries inherit without rendering values into the file).
|
||||
8. Provision (CA install, prompt copy, skills, git, supervise
|
||||
config) — unchanged, uses `docker exec`.
|
||||
9. Yield a DockerBottle handle. `exec_agent` runs claude via
|
||||
9. Yield a DockerBottle handle. `exec_claude` runs claude via
|
||||
`docker exec -it` exactly like the pre-compose world.
|
||||
|
||||
Teardown (ExitStack callbacks fire in reverse):
|
||||
@@ -43,7 +43,7 @@ from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...egress import egress_resolve_token_values
|
||||
from ...log import info, warn
|
||||
from ...log import info
|
||||
from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
@@ -63,11 +63,14 @@ from .compose import (
|
||||
compose_up,
|
||||
write_compose_file,
|
||||
)
|
||||
from .egress import egress_tls_init
|
||||
from .egress import DockerEgress, egress_tls_init
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import (
|
||||
BUNDLE_LOCAL_PIPELOCK_URL,
|
||||
DockerPipelockProxy,
|
||||
pipelock_proxy_url,
|
||||
pipelock_tls_init,
|
||||
)
|
||||
from .supervise import DockerSupervise
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
@@ -78,20 +81,30 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
def launch(
|
||||
plan: DockerBottlePlan,
|
||||
*,
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
egress: DockerEgress,
|
||||
supervise: DockerSupervise,
|
||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||
) -> Generator[DockerBottle, None, None]:
|
||||
"""Build, launch, and provision a Docker bottle via compose.
|
||||
Teardown on exit."""
|
||||
Teardown on exit. The per-sidecar `proxy / git_gate / egress /
|
||||
supervise` parameters are vestigial from the pre-compose flow —
|
||||
kept for backwards-compat with backend.py's call site; the
|
||||
`start()`/`stop()` methods on those classes are no longer
|
||||
invoked (chunk 3 collapsed them into the compose service spec).
|
||||
They'll be removed entirely in a follow-up cleanup."""
|
||||
del proxy, git_gate, egress, supervise # not invoked in compose flow
|
||||
|
||||
stack = ExitStack()
|
||||
|
||||
def teardown() -> None:
|
||||
try:
|
||||
stack.close()
|
||||
except BaseException as exc:
|
||||
warn(
|
||||
f"teardown failed for container {plan.container_name}"
|
||||
f" (compose-down): {exc!r}"
|
||||
)
|
||||
except BaseException:
|
||||
# Teardown must not raise; swallow so the caller's
|
||||
# __exit__ path can still propagate the original error.
|
||||
pass
|
||||
|
||||
try:
|
||||
# Step 1: agent image build. Sidecar images get built lazily by
|
||||
@@ -102,7 +115,7 @@ def launch(
|
||||
)
|
||||
if plan.derived_image:
|
||||
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
|
||||
@@ -150,7 +163,7 @@ def launch(
|
||||
mitmproxy_ca_host_path=egress_ca_host,
|
||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||
pipelock_ca_host_path=ca_cert_host,
|
||||
pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL,
|
||||
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
|
||||
)
|
||||
supervise_plan = plan.supervise_plan
|
||||
if supervise_plan is not None:
|
||||
@@ -177,10 +190,11 @@ def launch(
|
||||
# Step 7: compose up. Token values + the OAuth placeholder
|
||||
# flow through subprocess env; the compose file holds only
|
||||
# bare names for the secret-carrying entries.
|
||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.egress_plan.token_env_map, effective_env,
|
||||
)
|
||||
token_values: dict[str, str] = {}
|
||||
if plan.egress_plan.routes:
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.egress_plan.token_env_map, dict(os.environ),
|
||||
)
|
||||
compose_env: dict[str, str] = {
|
||||
**os.environ,
|
||||
**plan.forwarded_env,
|
||||
@@ -204,15 +218,9 @@ def launch(
|
||||
# the agent container by its known 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
|
||||
# service spec.
|
||||
yield DockerBottle(
|
||||
plan.container_name,
|
||||
teardown,
|
||||
prompt_path,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
)
|
||||
yield DockerBottle(plan.container_name, teardown, prompt_path)
|
||||
finally:
|
||||
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
|
||||
and similar upstream hostnames.
|
||||
|
||||
Naming: bot-bottle-net-<slug> (internal),
|
||||
bot-bottle-egress-<slug> (egress). Numeric suffix on conflict
|
||||
Naming: claude-bottle-net-<slug> (internal),
|
||||
claude-bottle-egress-<slug> (egress). Numeric suffix on conflict
|
||||
(-2, -3, ..., capped at 100).
|
||||
"""
|
||||
|
||||
@@ -20,11 +20,11 @@ from ...log import die, info, warn
|
||||
|
||||
|
||||
def network_name_for_slug(slug: str) -> str:
|
||||
return f"bot-bottle-net-{slug}"
|
||||
return f"claude-bottle-net-{slug}"
|
||||
|
||||
|
||||
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:
|
||||
@@ -0,0 +1,193 @@
|
||||
"""DockerPipelockProxy — the Docker-specific implementation of the
|
||||
sidecar's start/stop lifecycle. Inherits the platform-agnostic
|
||||
YAML-config generation from PipelockProxy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die, info, warn
|
||||
from ...pipelock import PipelockProxy, PipelockProxyPlan
|
||||
|
||||
|
||||
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
||||
PIPELOCK_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_PIPELOCK_IMAGE",
|
||||
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
||||
)
|
||||
|
||||
# Listening port for pipelock's forward proxy.
|
||||
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
|
||||
|
||||
# In-container paths where the per-bottle CA cert + key land after
|
||||
# `docker cp` in `DockerPipelockProxy.start`. Pipelock's rendered
|
||||
# YAML references these paths under `tls_interception`.
|
||||
PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem"
|
||||
PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem"
|
||||
|
||||
|
||||
def pipelock_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-pipelock-{slug}"
|
||||
|
||||
|
||||
def pipelock_proxy_url(slug: str) -> str:
|
||||
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
||||
|
||||
|
||||
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Generate a fresh per-bottle CA via a one-shot pipelock container.
|
||||
|
||||
Runs `pipelock tls init` against a host-mounted scratch dir, leaving
|
||||
`ca.pem` (public cert, mode 600) and `ca-key.pem` (private key, mode
|
||||
600) under `<stage_dir>/pipelock-ca/`. Returns the two host paths.
|
||||
|
||||
The image is pinned (same digest the running sidecar uses) so the
|
||||
generated CA matches what the sidecar expects. Output is owned by
|
||||
whatever UID the one-shot ran as; `DockerPipelockProxy.start`
|
||||
`docker cp`s the files into the sidecar's filesystem layer, so
|
||||
runtime ownership inside the sidecar (root in pipelock's
|
||||
distroless image) is independent."""
|
||||
work = stage_dir / "pipelock-ca"
|
||||
work.mkdir(exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{work}:/h",
|
||||
"-e", "PIPELOCK_HOME=/h",
|
||||
PIPELOCK_IMAGE, "tls", "init"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"pipelock tls init failed: {result.stderr.strip()}")
|
||||
cert = work / "ca.pem"
|
||||
key = work / "ca-key.pem"
|
||||
if not cert.is_file() or not key.is_file():
|
||||
die(f"pipelock tls init did not produce ca files in {work}")
|
||||
# Explicit perms in case a future pipelock release changes
|
||||
# defaults. Pipelock runs as root in its distroless image and
|
||||
# bind-mounts work with 0o600 (root reads everything); the key
|
||||
# has no reason to be readable to anyone else on the host.
|
||||
key.chmod(0o600)
|
||||
cert.chmod(0o644)
|
||||
return (cert, key)
|
||||
|
||||
|
||||
class DockerPipelockProxy(PipelockProxy):
|
||||
"""Brings the pipelock sidecar up and down via Docker."""
|
||||
|
||||
CA_CERT_IN_CONTAINER = PIPELOCK_CA_CERT_IN_CONTAINER
|
||||
CA_KEY_IN_CONTAINER = PIPELOCK_CA_KEY_IN_CONTAINER
|
||||
|
||||
def start(self, plan: PipelockProxyPlan) -> str:
|
||||
"""Boot the pipelock sidecar:
|
||||
1. `docker create` on the internal network with the canonical
|
||||
name and argv `run --config /etc/pipelock.yaml --listen
|
||||
0.0.0.0:<port>`.
|
||||
2. `docker cp` the YAML config to /etc/pipelock.yaml.
|
||||
3. `docker cp` the CA cert + key to /etc/pipelock-ca.pem
|
||||
and /etc/pipelock-ca-key.pem (pipelock runs as root in
|
||||
its distroless image, so no chown is needed).
|
||||
4. Attach to the per-agent egress network.
|
||||
5. `docker start`.
|
||||
Returns the container name (the proxy_target passed to .stop)."""
|
||||
name = pipelock_container_name(plan.slug)
|
||||
if not plan.yaml_path.is_file():
|
||||
die(
|
||||
f"pipelock yaml not found at {plan.yaml_path}; "
|
||||
f"PipelockProxy.prepare must run first"
|
||||
)
|
||||
if not plan.ca_cert_host_path.is_file() or not plan.ca_key_host_path.is_file():
|
||||
die(
|
||||
f"pipelock CA missing at {plan.ca_cert_host_path} / "
|
||||
f"{plan.ca_key_host_path}; pipelock_tls_init must run first"
|
||||
)
|
||||
|
||||
info(f"starting pipelock sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
PIPELOCK_IMAGE,
|
||||
"run", "--config", "/etc/pipelock.yaml",
|
||||
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
|
||||
]
|
||||
create_result = subprocess.run(
|
||||
create_args, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if create_result.returncode != 0:
|
||||
die(
|
||||
f"failed to create pipelock sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
for src, dst, label in (
|
||||
(plan.yaml_path, "/etc/pipelock.yaml", "yaml"),
|
||||
(plan.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER, "ca cert"),
|
||||
(plan.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER, "ca key"),
|
||||
):
|
||||
cp_result = subprocess.run(
|
||||
["docker", "cp", str(src), f"{name}:{dst}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if cp_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
die(f"failed to copy pipelock {label} into {name}: {cp_result.stderr.strip()}")
|
||||
|
||||
connect_result = subprocess.run(
|
||||
["docker", "network", "connect", plan.egress_network, name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if connect_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to attach pipelock sidecar {name} to egress network "
|
||||
f"{plan.egress_network}: {connect_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
start_result = subprocess.run(
|
||||
["docker", "start", name], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if start_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to start pipelock sidecar {name}: "
|
||||
f"{start_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
def stop(self, proxy_target: str) -> None:
|
||||
"""Idempotent: missing container is success. `proxy_target` is
|
||||
the container name returned by .start."""
|
||||
if subprocess.run(
|
||||
["docker", "inspect", proxy_target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0:
|
||||
if subprocess.run(
|
||||
["docker", "rm", "-f", proxy_target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove pipelock sidecar {proxy_target}; "
|
||||
f"clean up with 'docker rm -f {proxy_target}'"
|
||||
)
|
||||
+18
-26
@@ -5,9 +5,7 @@ Used by the supervise dashboard when the operator approves a
|
||||
pipelock-block proposal (or runs the operator-initiated `pipelock
|
||||
edit <bottle>` verb). Fetches the current pipelock.yaml via `docker
|
||||
exec`, parses it, swaps the api_allowlist with the proposed hosts,
|
||||
re-renders, writes back via the bind-mount path, then signals the
|
||||
bundle supervisor to restart the pipelock daemon (`docker kill
|
||||
--signal USR1`) so
|
||||
re-renders, writes back via `docker cp`, then `docker restart` so
|
||||
pipelock picks up the new config.
|
||||
|
||||
v1 uses restart, not SIGHUP — pipelock has no in-process reload
|
||||
@@ -27,7 +25,7 @@ from pathlib import Path
|
||||
from ...pipelock import pipelock_render_yaml
|
||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
from .bottle_state import pipelock_state_dir
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
from .pipelock import pipelock_container_name
|
||||
|
||||
|
||||
def _pipelock_yaml_host_path(slug: str) -> Path:
|
||||
@@ -75,15 +73,15 @@ def render_allowlist_content(hosts: list[str]) -> str:
|
||||
|
||||
|
||||
def fetch_current_yaml(slug: str) -> str:
|
||||
"""Read the live /etc/pipelock.yaml from the sidecar bundle.
|
||||
"""Read the live /etc/pipelock.yaml from the pipelock sidecar.
|
||||
|
||||
Uses `docker cp` because pipelock inside the bundle is the
|
||||
distroless pipelock binary with no shell, and `docker cp` is a
|
||||
daemon-API tarball copy that works regardless of what's
|
||||
available inside the container.
|
||||
Uses `docker cp` (not `docker exec cat`) because the pipelock
|
||||
image is distroless and has no shell utilities. `docker cp` is a
|
||||
daemon-API tarball copy — works on stopped containers too, and
|
||||
doesn't need anything in the container's PATH.
|
||||
|
||||
Raises PipelockApplyError if the read fails."""
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
container = pipelock_container_name(slug)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="cb-pipelock-fetch.", suffix=".yaml")
|
||||
os.close(fd)
|
||||
try:
|
||||
@@ -127,25 +125,19 @@ def fetch_current_allowlist(slug: str) -> str:
|
||||
def apply_allowlist_change(
|
||||
slug: str, new_allowlist_content: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Apply `new_allowlist_content` to the sidecar bundle:
|
||||
"""Apply `new_allowlist_content` to the pipelock sidecar:
|
||||
1. Parse the proposed hosts (one per line).
|
||||
2. Fetch + parse current pipelock.yaml.
|
||||
3. Replace api_allowlist with the proposed hosts; re-render.
|
||||
4. Write the new yaml to the bind-mount source.
|
||||
5. `docker kill --signal USR1 <bundle>` so the supervisor
|
||||
restarts the pipelock daemon in place (leaving egress,
|
||||
git-gate, and supervise running). Pipelock has no
|
||||
in-process reload; the supervisor's per-daemon restart
|
||||
keeps the agent's MCP socket alive — a whole-bundle
|
||||
`docker restart` would bounce supervise too.
|
||||
4. docker cp the new yaml into the sidecar.
|
||||
5. docker restart so pipelock reloads.
|
||||
|
||||
Returns (before, after) where both are one-per-line allowlist
|
||||
strings (operator-facing format). Raises PipelockApplyError on
|
||||
any failure; the sidecar's existing config stays in place until
|
||||
the host write succeeds, and the SIGUSR1 is what makes it
|
||||
live."""
|
||||
docker cp succeeds, and the restart is what makes it live."""
|
||||
new_hosts = parse_allowlist_content(new_allowlist_content)
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
container = pipelock_container_name(slug)
|
||||
current_yaml = fetch_current_yaml(slug)
|
||||
try:
|
||||
cfg = parse_yaml_subset(current_yaml)
|
||||
@@ -167,9 +159,9 @@ def apply_allowlist_change(
|
||||
# FILE — same Docker single-file inode issue as egress_apply:
|
||||
# write-temp-then-rename swaps the host inode and leaves the
|
||||
# container's mount pointing at the orphaned old one. Write
|
||||
# in-place. The SIGUSR1 below makes the new content live
|
||||
# (pipelock has no in-process reload, so the supervisor
|
||||
# restarts the pipelock daemon in response).
|
||||
# in-place. `docker restart` below picks up the new content
|
||||
# (and pipelock has no in-process reload anyway, so the
|
||||
# restart is what makes it live regardless of write atomicity).
|
||||
target = _pipelock_yaml_host_path(slug)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(rendered)
|
||||
@@ -177,12 +169,12 @@ def apply_allowlist_change(
|
||||
# fine — but 0o600 matches what prepare wrote.
|
||||
target.chmod(0o600)
|
||||
restart = subprocess.run(
|
||||
["docker", "kill", "--signal", "USR1", container],
|
||||
["docker", "restart", container],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if restart.returncode != 0:
|
||||
raise PipelockApplyError(
|
||||
f"failed to signal {container} for pipelock restart: "
|
||||
f"failed to restart {container}: "
|
||||
f"{(restart.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
@@ -12,20 +12,15 @@ 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 ...egress import Egress
|
||||
from ...env import ResolvedEnv, resolve_env
|
||||
from ...git_gate import GitGate
|
||||
from ...log import die
|
||||
from ...pipelock import PipelockProxy
|
||||
from ...supervise import Supervise
|
||||
from ...workspace import workspace_plan as resolve_workspace_plan
|
||||
from .. import BottleSpec
|
||||
from . import util as docker_mod
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import DockerEgress, egress_container_name
|
||||
from .git_gate import DockerGitGate, git_gate_container_name
|
||||
from .bottle_state import (
|
||||
BottleMetadata,
|
||||
agent_state_dir,
|
||||
@@ -40,31 +35,27 @@ from .bottle_state import (
|
||||
supervise_state_dir,
|
||||
write_metadata,
|
||||
)
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
from .pipelock import DockerPipelockProxy, pipelock_container_name
|
||||
from .supervise import DockerSupervise, supervise_container_name
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
stage_dir: Path,
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
egress: DockerEgress,
|
||||
supervise: DockerSupervise,
|
||||
) -> DockerBottlePlan:
|
||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||
that the agent and its skills/git-gate keys are present —
|
||||
validation already ran in the base class."""
|
||||
docker_mod.require_docker()
|
||||
|
||||
proxy = PipelockProxy()
|
||||
git_gate = GitGate()
|
||||
egress = Egress()
|
||||
supervise = Supervise()
|
||||
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
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`
|
||||
# mints a random-suffixed identity (so parallel runs of the same
|
||||
@@ -81,8 +72,7 @@ def resolve_plan(
|
||||
cwd=spec.user_cwd if spec.copy_cwd else "",
|
||||
copy_cwd=spec.copy_cwd,
|
||||
started_at=datetime.now(timezone.utc).isoformat(),
|
||||
compose_project=f"bot-bottle-{slug}",
|
||||
backend="docker",
|
||||
compose_project=f"claude-bottle-{slug}",
|
||||
))
|
||||
# Clear any leftover preserve marker from a prior capability-block
|
||||
# so this fresh launch can be cleaned up at session-end unless
|
||||
@@ -97,32 +87,26 @@ def resolve_plan(
|
||||
if per_bottle_dockerfile(slug) is not None:
|
||||
image_default = per_bottle_image_tag(slug)
|
||||
dockerfile_path = str(per_bottle_dockerfile_path(slug))
|
||||
elif provider.dockerfile:
|
||||
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||
elif provider_runtime.dockerfile:
|
||||
image_default = provider_runtime.image
|
||||
dockerfile_path = provider_runtime.dockerfile
|
||||
else:
|
||||
image_default = provider_runtime.image
|
||||
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
||||
image_default = "claude-bottle:latest"
|
||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
|
||||
derived_image = ""
|
||||
runtime_image = image
|
||||
if spec.copy_cwd:
|
||||
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
|
||||
|
||||
default_container = f"bot-bottle-{slug}"
|
||||
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
|
||||
default_container = f"claude-bottle-{slug}"
|
||||
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
|
||||
container_name_pinned = bool(pinned_container)
|
||||
if container_name_pinned:
|
||||
container_name = pinned_container
|
||||
if docker_mod.container_exists(container_name):
|
||||
die(
|
||||
f"container '{container_name}' already exists "
|
||||
f"(pinned via BOT_BOTTLE_CONTAINER). "
|
||||
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
|
||||
f"Remove it with 'docker rm -f {container_name}' or unset the override."
|
||||
)
|
||||
else:
|
||||
@@ -138,21 +122,33 @@ def resolve_plan(
|
||||
f"clean up old containers with 'docker rm -f <name>'"
|
||||
)
|
||||
|
||||
# Probe the sidecar-bundle container name for an orphan from a
|
||||
# previous run. Otherwise a stale bundle surfaces as a
|
||||
# docker-create conflict deep inside launch() with no actionable
|
||||
# hint; failing fast here points at the cleanup command.
|
||||
bundle_name = sidecar_bundle_container_name(slug)
|
||||
if docker_mod.container_exists(bundle_name):
|
||||
die(
|
||||
f"sidecar bundle container '{bundle_name}' already exists. "
|
||||
f"This is an orphan from a previous run; clean it up with "
|
||||
f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and "
|
||||
f"retry."
|
||||
)
|
||||
# Probe sidecar container names for orphans from a previous run.
|
||||
# Sidecar names are deterministic from the slug; an orphan would
|
||||
# surface as a docker-create conflict deep inside launch() with no
|
||||
# actionable hint. Fail fast here with a cleanup pointer instead.
|
||||
# Only probe sidecars this launch will actually try to create:
|
||||
# pipelock always; git-gate when bottle.git is non-empty;
|
||||
# egress when bottle.egress.routes is non-empty.
|
||||
sidecar_probes: list[tuple[str, str]] = [
|
||||
("pipelock", pipelock_container_name(slug)),
|
||||
]
|
||||
if bottle.git:
|
||||
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
||||
if bottle.egress.routes:
|
||||
sidecar_probes.append(("egress", egress_container_name(slug)))
|
||||
if bottle.supervise:
|
||||
sidecar_probes.append(("supervise", supervise_container_name(slug)))
|
||||
for label, sidecar_name in sidecar_probes:
|
||||
if docker_mod.container_exists(sidecar_name):
|
||||
die(
|
||||
f"{label} sidecar container '{sidecar_name}' already exists. "
|
||||
f"This is an orphan from a previous run; clean it up with "
|
||||
f"'./cli.py cleanup' (or 'docker rm -f {sidecar_name}') and "
|
||||
f"retry."
|
||||
)
|
||||
|
||||
# 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
|
||||
# cleaned up by start.py's session-end teardown unless something
|
||||
# explicitly preserves the state dir (capability-block, crash).
|
||||
@@ -163,45 +159,17 @@ def resolve_plan(
|
||||
prompt_file.write_text("")
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
egress_plan = egress.prepare(
|
||||
bottle, slug, egress_dir, agent_provision.egress_routes,
|
||||
)
|
||||
egress_plan = egress.prepare(bottle, slug, egress_dir)
|
||||
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
@@ -213,22 +181,41 @@ def resolve_plan(
|
||||
# PRD 0017 chunk 3 moved them behind the
|
||||
# `list-egress-routes` MCP tool so the agent gets live
|
||||
# state rather than a launch-time snapshot.)
|
||||
supervise_dockerfile_path = (
|
||||
Path(dockerfile_path)
|
||||
if dockerfile_path
|
||||
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||
)
|
||||
dockerfile_content = (
|
||||
supervise_dockerfile_path.read_text()
|
||||
if supervise_dockerfile_path.is_file()
|
||||
else ""
|
||||
)
|
||||
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
||||
supervise_dir = supervise_state_dir(slug)
|
||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||
supervise_plan = supervise.prepare(
|
||||
slug, supervise_dir,
|
||||
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(
|
||||
spec=spec,
|
||||
@@ -248,8 +235,6 @@ def resolve_plan(
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=use_runsc,
|
||||
agent_provision=agent_provision,
|
||||
workspace_plan=workspace_plan,
|
||||
)
|
||||
|
||||
|
||||
@@ -268,10 +253,3 @@ def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
|
||||
env_lines.append(f"{name}={value}")
|
||||
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
|
||||
env_file.chmod(0o600)
|
||||
|
||||
|
||||
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||
path = Path(os.path.expanduser(path_value))
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
return str(path)
|
||||
+43
-3
@@ -31,18 +31,54 @@ stage dir; nothing in the agent ever sees it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ssl
|
||||
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
|
||||
|
||||
|
||||
# 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:
|
||||
"""Copy the agent-facing CA cert into the agent, rebuild the
|
||||
trust bundle, emit a one-line fingerprint log. Called from
|
||||
`BottleBackend.provision` after the agent container is up."""
|
||||
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(
|
||||
["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,
|
||||
)
|
||||
|
||||
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,102 @@
|
||||
"""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 ....log import info
|
||||
from ....manifest import GitEntry
|
||||
from .. import util as docker_mod
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
from ..git_gate import git_gate_host
|
||||
|
||||
|
||||
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 render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
||||
"""Render the ~/.gitconfig content for git-gate `insteadOf`
|
||||
rewrites. Pure host-side, no docker; exposed for tests.
|
||||
|
||||
Empty `entries` returns an empty string so callers can no-op
|
||||
cleanly without conditional formatting at the call site."""
|
||||
if not entries:
|
||||
return ""
|
||||
gate = git_gate_host(slug)
|
||||
out = [
|
||||
"# claude-bottle git-gate (PRD 0008): every git operation against\n",
|
||||
"# a declared upstream routes through the gate, which mirrors\n",
|
||||
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||
]
|
||||
for entry in entries:
|
||||
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n')
|
||||
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
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 = render_git_gate_gitconfig(plan.slug, bottle.git)
|
||||
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])
|
||||
+2
-2
@@ -18,8 +18,8 @@ def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
|
||||
prompt (drives --append-system-prompt-file), else None. The
|
||||
file is copied either way so the path always exists."""
|
||||
container = target
|
||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
in_container_prompt_path = f"{container_home}/.claude-bottle-prompt.txt"
|
||||
|
||||
subprocess.run(
|
||||
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
|
||||
+2
-2
@@ -28,9 +28,9 @@ def provision_skills(plan: DockerBottlePlan, target: str) -> None:
|
||||
return
|
||||
|
||||
container = target
|
||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
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(
|
||||
@@ -0,0 +1,131 @@
|
||||
"""DockerSupervise — the Docker-specific lifecycle for the per-bottle
|
||||
supervise sidecar (PRD 0013). Inherits the platform-agnostic prepare
|
||||
step (queue dir + current-config staging) from `Supervise`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die, info, warn
|
||||
from ...supervise import (
|
||||
QUEUE_DIR_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
Supervise,
|
||||
SupervisePlan,
|
||||
)
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
SUPERVISE_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_SUPERVISE_IMAGE",
|
||||
"claude-bottle-supervise:latest",
|
||||
)
|
||||
|
||||
SUPERVISE_DOCKERFILE = "Dockerfile.supervise"
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def supervise_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-supervise-{slug}"
|
||||
|
||||
|
||||
def supervise_url() -> str:
|
||||
"""Base URL the agent's MCP client dials. Stable across bottles
|
||||
because the sidecar attaches `--network-alias supervise` on the
|
||||
internal network."""
|
||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}"
|
||||
|
||||
|
||||
def build_supervise_image() -> None:
|
||||
"""Build the supervise image from `Dockerfile.supervise`. Called
|
||||
by `DockerSupervise.start`; exposed at module level so tests can
|
||||
build it without running the full launch pipeline."""
|
||||
docker_mod.build_image(SUPERVISE_IMAGE, _REPO_DIR, dockerfile=SUPERVISE_DOCKERFILE)
|
||||
|
||||
|
||||
class DockerSupervise(Supervise):
|
||||
"""Brings the supervise sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: SupervisePlan) -> str:
|
||||
"""Boot the supervise sidecar:
|
||||
1. Build the supervise image (no-op when cache is hot).
|
||||
2. `docker create` on the internal network with
|
||||
`--network-alias supervise` and SUPERVISE_BOTTLE_SLUG in
|
||||
the environ.
|
||||
3. Bind-mount the host queue dir at /run/supervise/queue.
|
||||
4. `docker start`.
|
||||
No egress network — the supervise sidecar does not make
|
||||
outbound calls. Returns the container name."""
|
||||
if not plan.internal_network:
|
||||
die("DockerSupervise.start: plan.internal_network must be set before start")
|
||||
if not plan.queue_dir.is_dir():
|
||||
die(
|
||||
f"DockerSupervise.start: queue dir missing at {plan.queue_dir}; "
|
||||
f"Supervise.prepare must run first"
|
||||
)
|
||||
|
||||
build_supervise_image()
|
||||
|
||||
name = supervise_container_name(plan.slug)
|
||||
info(f"starting supervise sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
"--network-alias", SUPERVISE_HOSTNAME,
|
||||
"-e", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
"-e", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
"-e", f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
"-v", f"{plan.queue_dir}:{QUEUE_DIR_IN_CONTAINER}",
|
||||
SUPERVISE_IMAGE,
|
||||
]
|
||||
|
||||
create_result = subprocess.run(
|
||||
create_args, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if create_result.returncode != 0:
|
||||
die(
|
||||
f"failed to create supervise sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
start_result = subprocess.run(
|
||||
["docker", "start", name], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if start_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to start supervise sidecar {name}: "
|
||||
f"{start_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
def stop(self, target: str) -> None:
|
||||
"""Idempotent: missing container is success."""
|
||||
if subprocess.run(
|
||||
["docker", "inspect", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0:
|
||||
if subprocess.run(
|
||||
["docker", "rm", "-f", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove supervise sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
@@ -7,11 +7,9 @@ from __future__ import annotations
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
from ...log import die, info
|
||||
from ...workspace import WorkspacePlan
|
||||
|
||||
|
||||
# Cap on the suffix the container-name conflict logic will try before
|
||||
@@ -118,69 +116,35 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def build_image_with_cwd(
|
||||
derived: str,
|
||||
base: str,
|
||||
workspace: WorkspacePlan,
|
||||
) -> None:
|
||||
"""Build a thin derived image that copies the workspace into
|
||||
the plan's guest path and sets the plan's workdir."""
|
||||
_TRUST_DIALOG_NODE_SCRIPT = (
|
||||
'const fs=require("fs"),p=process.env.HOME+"/.claude.json",'
|
||||
'c=JSON.parse(fs.readFileSync(p,"utf8"));'
|
||||
'c.projects=c.projects||{};'
|
||||
'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};'
|
||||
'fs.writeFileSync(p,JSON.stringify(c,null,2));'
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
cwd = str(workspace.host_path)
|
||||
if not os.path.isdir(cwd):
|
||||
die(f"cwd not found at {cwd}")
|
||||
info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
|
||||
context_dir = os.path.join(tmp, "context")
|
||||
staged_workspace = os.path.join(context_dir, "workspace")
|
||||
shutil.copytree(
|
||||
cwd,
|
||||
staged_workspace,
|
||||
symlinks=True,
|
||||
ignore=shutil.ignore_patterns(".git"),
|
||||
)
|
||||
dockerfile = (
|
||||
f"FROM {base}\n"
|
||||
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:
|
||||
"""Return the content-addressed image ID (e.g.
|
||||
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
|
||||
`.smolmachine` artifact cache on this, so a Dockerfile change
|
||||
that produces a new image automatically invalidates the cache."""
|
||||
r = subprocess.run(
|
||||
["docker", "image", "inspect", "--format", "{{.Id}}", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace")
|
||||
dockerfile = (
|
||||
f"FROM {base}\n"
|
||||
f"COPY --chown=node:node . /home/node/workspace\n"
|
||||
f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n"
|
||||
f"WORKDIR /home/node/workspace\n"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", derived, "-f", "-", cwd],
|
||||
input=dockerfile,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
die(
|
||||
f"docker image inspect for {ref!r} failed: "
|
||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def save(ref: str, output: str) -> None:
|
||||
"""`docker save REF -o OUTPUT`. Writes a tarball of the image
|
||||
layers + manifest to the host path. Used by smolmachines
|
||||
prepare to hand the agent image to a containerized crane that
|
||||
pushes it to the ephemeral registry — bypassing the docker
|
||||
daemon's `docker push` (which on Docker Desktop can't reach a
|
||||
host-loopback registry and refuses plain-HTTP pushes to
|
||||
non-loopback hosts)."""
|
||||
subprocess.run(["docker", "save", ref, "-o", output], check=True)
|
||||
|
||||
|
||||
def _silent_run(cmd: Iterable[str]) -> int:
|
||||
@@ -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
|
||||
|
||||
from ..log import Die, die, error
|
||||
from ..manifest import ManifestError
|
||||
from ..log import Die, die
|
||||
from ._common import PROG
|
||||
from . import list as _list_mod
|
||||
from .cleanup import cmd_cleanup
|
||||
@@ -36,11 +35,11 @@ COMMANDS = {
|
||||
def usage() -> None:
|
||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||
sys.stderr.write("Commands:\n")
|
||||
sys.stderr.write(" cleanup stop and remove all active 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(" edit open an agent in vim for editing\n")
|
||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||
sys.stderr.write(" init interactively create a new agent and add it to 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(" 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")
|
||||
@@ -64,11 +63,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
die(f"unknown command: {command}")
|
||||
try:
|
||||
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:
|
||||
return e.code if isinstance(e.code, int) else 1
|
||||
except KeyboardInterrupt:
|
||||
@@ -0,0 +1,47 @@
|
||||
"""cleanup: stop and remove all orphaned claude-bottle resources.
|
||||
|
||||
PRD 0018 chunk 4: backend's prepare_cleanup carries everything in
|
||||
one plan — live compose projects (whose `compose down` removes
|
||||
containers + networks atomically), legacy stray containers/networks
|
||||
that aren't in any project, and orphan state dirs (per-bottle
|
||||
state with no live project AND no `.preserve` marker). One prompt,
|
||||
one cleanup call.
|
||||
|
||||
State dirs with `.preserve` are intentionally never touched — they
|
||||
hold capability-block rebuilds or crash snapshots the operator may
|
||||
want to `resume`. Manual `rm -rf ~/.claude-bottle/state/<identity>`
|
||||
is the path for those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ..backend import get_bottle_backend
|
||||
from ..log import info
|
||||
from ._common import read_tty_line
|
||||
|
||||
|
||||
def cmd_cleanup(_argv: list[str]) -> int:
|
||||
backend = get_bottle_backend()
|
||||
plan = backend.prepare_cleanup()
|
||||
|
||||
if plan.empty:
|
||||
info("no claude-bottle resources to clean up")
|
||||
return 0
|
||||
|
||||
plan.print()
|
||||
if not _prompt_yes("remove all of the above?"):
|
||||
info("cleanup: skipped")
|
||||
return 0
|
||||
|
||||
backend.cleanup(plan)
|
||||
info("cleanup: done")
|
||||
return 0
|
||||
|
||||
|
||||
def _prompt_yes(message: str) -> bool:
|
||||
sys.stderr.write(f"claude-bottle: {message} [y/N] ")
|
||||
sys.stderr.flush()
|
||||
reply = read_tty_line()
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,9 +18,9 @@ def cmd_edit(argv: list[str]) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.scope == "user":
|
||||
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
|
||||
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
||||
else:
|
||||
target_file = Path(USER_CWD) / "bot-bottle.json"
|
||||
target_file = Path(USER_CWD) / "claude-bottle.json"
|
||||
|
||||
if not target_file.is_file():
|
||||
die(f"{target_file} does not exist")
|
||||
@@ -11,7 +11,7 @@ from ._common import PROG, USER_CWD
|
||||
|
||||
def cmd_info(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
|
||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||
parser.add_argument("name", help="agent name defined in claude-bottle.json")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
@@ -31,9 +31,6 @@ def cmd_info(argv: list[str]) -> int:
|
||||
f"first line: {prompt_first_line or '(empty)'}"
|
||||
)
|
||||
info(f"bottle : {agent.bottle}")
|
||||
identity = manifest.git_identity_summary(args.name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
if bottle.git:
|
||||
for e in bottle.git:
|
||||
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
|
||||
|
||||
@@ -20,12 +20,12 @@ def cmd_init(argv: list[str]) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.scope == "user":
|
||||
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
|
||||
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
||||
else:
|
||||
target_file = Path(USER_CWD) / "bot-bottle.json"
|
||||
target_file = Path(USER_CWD) / "claude-bottle.json"
|
||||
|
||||
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)
|
||||
|
||||
# Agent name
|
||||
@@ -51,7 +51,7 @@ def cmd_init(argv: list[str]) -> int:
|
||||
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
||||
if agent_name in (existing.get("agents") or {}):
|
||||
sys.stderr.write(
|
||||
f'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()
|
||||
ow = read_tty_line()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user