Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f72843150 | |||
| fd6b14fb32 | |||
| 9f9aa2e762 | |||
| 454baaf3a1 | |||
| 8a092504b8 | |||
| e7dacf7d86 | |||
| 9b929d0684 | |||
| ec41f629a4 | |||
| d9a9eef276 | |||
| 5204b98777 | |||
| 14ae89580a | |||
| 4808ef557a | |||
| 0a7e166b35 | |||
| a920203730 | |||
| e02fab15d0 | |||
| 11cf12188d | |||
| 701df6cb2f | |||
| ea6bc5a170 | |||
| ecaae708f7 | |||
| 2e790268b0 | |||
| a421d1d688 | |||
| d2d50be65a | |||
| 1ad710a041 | |||
| b411577e76 | |||
| cdfaaa3de8 | |||
| 7f2352287e | |||
| 7cb967770e | |||
| 80eca740d6 | |||
| 369d332204 | |||
| 31cde11b0d | |||
| c41751f3b9 | |||
| e2422c20a0 | |||
| de71533a17 | |||
| 88c4f61901 | |||
| c666eaa63f | |||
| 83eb9e4041 | |||
| 33333ac4d9 | |||
| 4d56f515bc | |||
| c48c3688b8 | |||
| 6040b20e6e | |||
| f2775101a0 | |||
| dd99c495f4 | |||
| eb64a52ffa | |||
| d11e3940fa | |||
| a32c0c7865 | |||
| ccb2956562 | |||
| c6362fda7b | |||
| cb321f7ad4 | |||
| 311cd46185 | |||
| 28335f453f | |||
| a1aa8feb85 | |||
| cb3bb209d6 | |||
| 6e73cc4d86 | |||
| 64fac71025 | |||
| f8ac22c316 | |||
| 9465857a99 | |||
| 200306f1cf | |||
| 77bdaf0a96 | |||
| 7e344bbb53 | |||
| 5eb27cd9a8 | |||
| 5808d0b828 | |||
| 7a991e1f5e | |||
| 5606797ac2 | |||
| ebbb4053cf | |||
| eb3e64ea8f | |||
| 0ec1085238 | |||
| 4c39b45e34 | |||
| 3ea35ba5d2 | |||
| 7c6ab62e26 | |||
| da42740156 | |||
| 56ef71060a | |||
| 294a6ed023 | |||
| 468ab8c290 | |||
| 2596c18954 | |||
| 3ccd09ed0d | |||
| 996a260a98 | |||
| 3375df3f52 | |||
| c9842ce831 | |||
| d314ccf455 | |||
| 31b29631b6 | |||
| 1c11110da5 | |||
| 25ca14a8a2 | |||
| b5b7f15ef9 | |||
| 85e64b5134 | |||
| 1a5b6e25f8 | |||
| 54760964cf | |||
| e463670649 | |||
| 6e6890ebd9 | |||
| 609b3ed090 | |||
| 65faa40b9a | |||
| 9f97de115b | |||
| 8f21f4df19 | |||
| ff7a52c1d2 | |||
| 4ed6b84863 | |||
| 7a124d7d25 | |||
| f00c567469 | |||
| 6f0e5b4589 | |||
| 5da4d05bf2 | |||
| 1a8718ca9d | |||
| c1c225aa05 | |||
| dc7c10d6fe | |||
| a827b0841e | |||
| a9c93ea9df | |||
| bb69af31f8 | |||
| 7644da4280 | |||
| 13e4af421d | |||
| f2d5307573 | |||
| bc9a22b46a | |||
| 932e71c0bf | |||
| d3b0b330aa | |||
| 5e927bcd13 | |||
| 890a146413 | |||
| afdf0779a1 | |||
| eb7cae1fea | |||
| fe82dc7f2b | |||
| b00b0ba4aa | |||
| 3f04567290 | |||
| acb9cd67c6 | |||
| d90ab7e646 | |||
| 8ea90adcaf | |||
| de803e1e76 | |||
| 019efab804 | |||
| 957d37f51f | |||
| 8e084262a0 | |||
| 504144eb9c | |||
| 86374ab293 | |||
| 199edb228c | |||
| 598a20a3f0 | |||
| c8b5ba3812 | |||
| 5ea9fda69b | |||
| 4f7cfc0418 | |||
| 1f38a96561 | |||
| 660b9b3810 | |||
| 328069809b | |||
| b1551045dc | |||
| d02226aab9 | |||
| 39811c9b32 | |||
| f7f161e60f | |||
| e6040fc824 | |||
| 17fc44d0d8 | |||
| 1bebb7467f | |||
| cc1d986a74 | |||
| fabcd026af | |||
| aff042855a | |||
| 39b0c4f720 | |||
| 43a5700ae6 | |||
| 7acdabaf96 | |||
| dfd2d5f620 | |||
| f24e2857ab | |||
| d38432f640 | |||
| 4e570e3e2b | |||
| a64e3170cd | |||
| 4da4babcf4 | |||
| 384e496a1b | |||
| b38c6110f2 | |||
| 74efb1c143 | |||
| f23b2b9683 | |||
| 423003aa05 | |||
| af82f2ba20 | |||
| fe8e15d211 | |||
| b098556757 | |||
| 5c5f277d6d | |||
| 2fa5229695 | |||
| c3caa3ea94 | |||
| ee0607f022 | |||
| afe5d43a9a | |||
| dd332a5759 | |||
| 103f9adcfd | |||
| 652c8cb5a7 | |||
| 11a8f3ba99 | |||
| 451e6fc2fc | |||
| 1ecef55fea | |||
| 76e38b24e6 | |||
| b1283a0e7b | |||
| 2c51bc47e8 | |||
| ff495c1521 | |||
| a04aed098d | |||
| 916b70c595 | |||
| 55cb3429d4 | |||
| 545ff3582f | |||
| 8743299226 | |||
| 205e94f960 | |||
| 86b0a4d285 | |||
| 79212481c9 | |||
| 76dd153760 | |||
| b8d10abec9 | |||
| 7ebddf7792 | |||
| 04d7ca2e6a | |||
| f6f47c2f23 | |||
| 39e0976ace | |||
| 299579ab7b | |||
| 3a10c38511 | |||
| db54f3d0b4 | |||
| 8105e93031 | |||
| 0d5c2f1a2e | |||
| bba24d87f7 | |||
| efb3af4a93 | |||
| 65746af720 | |||
| d9e9d27e01 | |||
| 83351606c6 | |||
| d528f578aa | |||
| cf3310e818 | |||
| 74d6b25183 | |||
| dc837a5400 | |||
| 4eff49c9c5 | |||
| 965d5073c3 |
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Run pylint
|
||||
run: |
|
||||
# Run pylint on all Python files in the repo
|
||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0 || true
|
||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
||||
|
||||
- name: Run pyright
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Assign sequential numbers to prd-new-*.md files on merge to main.
|
||||
#
|
||||
# When a PR merges to main and includes prd-new-*.md files this workflow:
|
||||
# 1. Finds the next available NNNN number by scanning existing PRDs.
|
||||
# 2. Renames each prd-new-*.md to NNNN-<slug>.md.
|
||||
# 3. Updates the title header (# PRD prd-new: → # PRD NNNN:).
|
||||
# 4. Flips Status: Draft → Active when the push touched files outside
|
||||
# docs/prds/ anywhere in its commit range (i.e. the implementation
|
||||
# shipped together with the PRD).
|
||||
# 5. Commits the renaming back to main.
|
||||
#
|
||||
# No-op if the working tree contains no prd-new-*.md files.
|
||||
#
|
||||
# NOTE: The workflow scans the working tree (not just HEAD~1..HEAD) because
|
||||
# PRs land as multi-commit pushes and the prd-new file is often added in an
|
||||
# earlier commit on the branch, not in the final squash/merge commit.
|
||||
|
||||
name: prd-number
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/prds/prd-new-*.md'
|
||||
|
||||
jobs:
|
||||
assign-numbers:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Assign PRD numbers
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
prds_dir = Path("docs/prds")
|
||||
|
||||
# Scan the working tree — prd-new files may have landed in any
|
||||
# commit of a multi-commit push, not just HEAD.
|
||||
new_prds = sorted(prds_dir.glob("prd-new-*.md"))
|
||||
|
||||
if not new_prds:
|
||||
print("No prd-new-*.md files found — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
# Determine whether non-PRD files were also changed anywhere in
|
||||
# the push range (BEFORE_SHA → HEAD). Falls back to HEAD~1 when
|
||||
# the env var isn't set (e.g. local act runs).
|
||||
before_sha = os.environ.get("GITHUB_EVENT_BEFORE", "HEAD~1")
|
||||
all_changed = subprocess.run(
|
||||
["git", "diff", "--name-only", before_sha, "HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.splitlines()
|
||||
non_prd_changed = any(
|
||||
not f.startswith("docs/prds/") for f in all_changed
|
||||
)
|
||||
|
||||
# Find next available number.
|
||||
existing = sorted(
|
||||
int(m.group(1))
|
||||
for p in prds_dir.glob("*.md")
|
||||
if (m := re.match(r"^(\d{4})-", p.name))
|
||||
)
|
||||
next_num = (max(existing) + 1) if existing else 1
|
||||
|
||||
for prd_path in sorted(new_prds):
|
||||
slug = re.sub(r"^prd-new-", "", prd_path.stem)
|
||||
new_name = f"{next_num:04d}-{slug}.md"
|
||||
new_path = prds_dir / new_name
|
||||
print(f" {prd_path.name} → {new_name}")
|
||||
|
||||
content = prd_path.read_text()
|
||||
|
||||
# Update title header.
|
||||
content = re.sub(
|
||||
r"^(#\s+PRD\s+)prd-new(:)",
|
||||
rf"\g<1>{next_num:04d}\2",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# Conditionally flip Status.
|
||||
if non_prd_changed:
|
||||
content = re.sub(
|
||||
r"(\*\*Status:\*\*\s*)Draft",
|
||||
r"\g<1>Active",
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
new_path.write_text(content)
|
||||
subprocess.run(["git", "rm", str(prd_path)], check=True)
|
||||
subprocess.run(["git", "add", str(new_path)], check=True)
|
||||
next_num += 1
|
||||
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "ci(prd): assign sequential numbers to new PRDs"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "push"], check=True)
|
||||
EOF
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
## 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.
|
||||
bot-bottle spins up an isolated backend runtime 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 runtime lifecycle and the copying of skills and env vars into it.
|
||||
The default backend on compatible macOS hosts is macos-container:
|
||||
agents and sidecar bundles run through Apple's `container` CLI without
|
||||
requiring Docker. The smolmachines backend remains available with
|
||||
`BOT_BOTTLE_BACKEND=smolmachines` or `--backend=smolmachines`; agents
|
||||
run in a libkrun micro-VM, while the sidecar bundle still uses Docker.
|
||||
The legacy Docker backend remains available with `BOT_BOTTLE_BACKEND=docker`
|
||||
or `--backend=docker`.
|
||||
|
||||
## Goals
|
||||
|
||||
@@ -17,7 +24,7 @@ the container lifecycle and the copying of skills and env vars into it.
|
||||
## Non-goals
|
||||
|
||||
- Communicating between agents directly
|
||||
- Self hosted VMs (v1 uses local Docker containers, not VMs)
|
||||
- Removing the Docker backend
|
||||
- Advanced agent auditing (lean on git history for auditing)
|
||||
|
||||
## Repository layout
|
||||
@@ -36,10 +43,11 @@ the container lifecycle and the copying of skills and env vars into it.
|
||||
|
||||
- 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`.
|
||||
- **PRDs** (`docs/prds/`) — one feature per file. While a PR is open
|
||||
the file is named `prd-new-<kebab>.md`; CI assigns a sequential
|
||||
number on merge to `main` and renames it. 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`.
|
||||
|
||||
@@ -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"]
|
||||
@@ -5,7 +5,7 @@
|
||||
# bot-bottle
|
||||
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||
[](https://github.com/PyCQA/pylint)
|
||||
[](https://github.com/PyCQA/pylint)
|
||||
[](https://github.com/microsoft/pyright)
|
||||
|
||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||
@@ -14,20 +14,29 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
||||
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
|
||||
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
||||
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
|
||||
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
|
||||
- **Parallel, isolated bottles** — each bottle is its own per-agent Docker `--internal` network; bottles don't share state or talk to each other.
|
||||
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
||||
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||
- **Smolmachines backend (macOS)** — opt-in `BOT_BOTTLE_BACKEND=smolmachines` runs the agent in a libkrun micro-VM with the sidecar bundle still in Docker.
|
||||
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
||||
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
|
||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
||||
|
||||
## Architecture
|
||||
|
||||
A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles egress + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
||||
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
||||
|
||||
On the smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS.
|
||||
|
||||
On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
||||
|
||||
The Docker topology looks like this:
|
||||
|
||||
```
|
||||
host ( ./cli.py )
|
||||
@@ -62,7 +71,9 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
|
||||
|
||||
## Quickstart
|
||||
|
||||
Requires Docker on the host and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||
|
||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
|
||||
|
||||
```sh
|
||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||
@@ -96,8 +107,15 @@ egress:
|
||||
routes:
|
||||
- host: gitea.dideric.is
|
||||
auth:
|
||||
scheme: token
|
||||
scheme: token # Bearer | token
|
||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||
matches: # optional — restrict to specific paths/methods/headers
|
||||
- paths:
|
||||
- {type: prefix, value: /api/v1/}
|
||||
methods: [GET, POST, PATCH, DELETE]
|
||||
dlp: # optional — per-route detector overrides (default: all on)
|
||||
outbound_detectors: [token_patterns, known_secrets]
|
||||
inbound_detectors: false # disable response scanning for this host
|
||||
---
|
||||
|
||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||
@@ -116,6 +134,26 @@ skills:
|
||||
You help maintain Gitea-hosted projects.
|
||||
````
|
||||
|
||||
**Egress route fields:**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `host` | yes | Hostname to allowlist. One entry per host. |
|
||||
| `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. |
|
||||
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
|
||||
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
|
||||
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
|
||||
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
|
||||
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
|
||||
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
|
||||
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
|
||||
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
|
||||
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
|
||||
| `dlp.outbound_on_match` | no | What to do when an outbound token is detected: `supervise` (default for manifest routes — hold for operator approval), `redact` (scrub the value and forward), or `block` (hard 403). Agent-provider routes (e.g. `api.anthropic.com`) default to `redact`. |
|
||||
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||
|
||||
When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `./cli.py supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy.
|
||||
|
||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||
|
||||
## Trademarks
|
||||
|
||||
+175
-12
@@ -19,6 +19,11 @@ Per PRD 0050 the per-provider implementations live under
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import inspect
|
||||
import os
|
||||
import shlex
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -33,13 +38,19 @@ if TYPE_CHECKING:
|
||||
|
||||
PROVIDER_CLAUDE = "claude"
|
||||
PROVIDER_CODEX = "codex"
|
||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
||||
PROVIDER_PI = "pi"
|
||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
||||
|
||||
# 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"]
|
||||
PromptMode = Literal[
|
||||
"append_file",
|
||||
"read_prompt_file",
|
||||
"print_read_prompt_file",
|
||||
"append_system_prompt",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -47,11 +58,9 @@ 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)
|
||||
@@ -99,7 +108,12 @@ class AgentProvisionPlan:
|
||||
prompt_mode: PromptMode
|
||||
image: str
|
||||
dockerfile: str
|
||||
guest_home: str
|
||||
instance_name: str
|
||||
prompt_file: Path
|
||||
guest_env: dict[str, str]
|
||||
has_prompt: bool = False
|
||||
startup_args: tuple[str, ...] = ()
|
||||
env_vars: dict[str, str] = field(default_factory=dict)
|
||||
dirs: tuple[AgentProvisionDir, ...] = ()
|
||||
files: tuple[AgentProvisionFile, ...] = ()
|
||||
@@ -123,18 +137,39 @@ class AgentProvider(ABC):
|
||||
"""The static command / image / prompt-mode table for this
|
||||
template."""
|
||||
|
||||
@property
|
||||
def guest_home(self) -> str:
|
||||
"""In-guest home directory for the agent user. Defaults to
|
||||
`/home/node` to match the Debian-based bot-bottle-* images
|
||||
(USER node). Override for plugins whose image runs as a
|
||||
different user."""
|
||||
return "/home/node"
|
||||
|
||||
@property
|
||||
def dockerfile(self) -> Path:
|
||||
"""Path to the provider's Dockerfile.
|
||||
|
||||
Default: the `Dockerfile` file next to this provider's
|
||||
`agent_provider.py` module. Override to point at a non-standard
|
||||
path."""
|
||||
return Path(inspect.getfile(type(self))).parent / "Dockerfile"
|
||||
|
||||
@abstractmethod
|
||||
def provision_plan(
|
||||
self,
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
instance_name: str,
|
||||
prompt_file: Path,
|
||||
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 = "",
|
||||
label: str = "",
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
"""Build the declarative AgentProvisionPlan for one launch.
|
||||
Backends call this during `prepare` and consume the result as
|
||||
@@ -174,19 +209,126 @@ class AgentProvider(ABC):
|
||||
the supervise sidecar is reachable. No-op when
|
||||
`plan.supervise_plan is None`."""
|
||||
|
||||
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
||||
"""Install the egress MITM CA into the agent's trust store.
|
||||
|
||||
Default: Debian-style — cp the cert to the standard source path,
|
||||
run update-ca-certificates, log the fingerprint. Override for
|
||||
non-Debian base images or non-standard trust mechanisms."""
|
||||
from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
||||
from .log import die
|
||||
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||
r = bottle.exec(
|
||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||
user="root",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
die(
|
||||
f"update-ca-certificates failed (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 provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
||||
"""Configure git inside the agent container.
|
||||
|
||||
Default: Debian/node — writes the git-gate insteadOf gitconfig
|
||||
and sets user.name/email as node. Workspace copy runs through
|
||||
BottleBackend.provision_workspace against the running bottle."""
|
||||
from .log import info
|
||||
|
||||
manifest_bottle = plan.manifest.bottle
|
||||
if manifest_bottle.git:
|
||||
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
||||
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
|
||||
content = git_gate_render_gitconfig(
|
||||
manifest_bottle.git, gate_host, scheme=gate_scheme,
|
||||
)
|
||||
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||
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 "
|
||||
f"{len(manifest_bottle.git)} insteadOf rule(s)"
|
||||
)
|
||||
bottle.cp_in(str(config_file), guest_gitconfig)
|
||||
bottle.exec(
|
||||
f"chown node:node {shlex.quote(guest_gitconfig)} && "
|
||||
f"chmod 644 {shlex.quote(guest_gitconfig)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
gu = manifest_bottle.git_user
|
||||
if not gu.is_empty():
|
||||
if gu.name:
|
||||
info(f"git config --global user.name = {gu.name!r}")
|
||||
bottle.exec(
|
||||
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||
user="node",
|
||||
)
|
||||
if gu.email:
|
||||
info(f"git config --global user.email = {gu.email!r}")
|
||||
bottle.exec(
|
||||
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||
user="node",
|
||||
)
|
||||
|
||||
|
||||
def _load_user_plugin(template: str) -> AgentProvider | None:
|
||||
"""Check ~/.bot-bottle/contrib/<template>/agent_provider.py for a
|
||||
user-defined AgentProvider subclass. Returns an instance if found,
|
||||
None if the plugin directory doesn't exist, raises ValueError if
|
||||
the file exists but exports no AgentProvider subclass."""
|
||||
plugin_path = (
|
||||
Path.home() / ".bot-bottle" / "contrib" / template / "agent_provider.py"
|
||||
)
|
||||
if not plugin_path.exists():
|
||||
return None
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"_user_contrib_{template}.agent_provider", plugin_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValueError(f"user plugin at {plugin_path} could not be loaded")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
for obj in vars(mod).values():
|
||||
if (
|
||||
isinstance(obj, type)
|
||||
and issubclass(obj, AgentProvider)
|
||||
and obj is not AgentProvider
|
||||
):
|
||||
return obj()
|
||||
raise ValueError(
|
||||
f"user plugin at {plugin_path} defines no AgentProvider subclass"
|
||||
)
|
||||
|
||||
|
||||
def get_provider(template: str) -> AgentProvider:
|
||||
"""Resolve a provider template name to its plugin instance.
|
||||
|
||||
Lazy-imports the contrib module so importing this module doesn't
|
||||
pull provider-specific code paths in. Mirrors the contrib
|
||||
convention PRD 0048 established for deploy key provisioners."""
|
||||
Checks ~/.bot-bottle/contrib/<template>/agent_provider.py first so
|
||||
users can shadow a built-in for local testing. Falls through to the
|
||||
built-in registry; raises ValueError for unknown names with no
|
||||
matching user plugin."""
|
||||
user_plugin = _load_user_plugin(template)
|
||||
if user_plugin is not None:
|
||||
return user_plugin
|
||||
if template == PROVIDER_CLAUDE:
|
||||
from .contrib.claude.agent_provider import ClaudeAgentProvider
|
||||
return ClaudeAgentProvider()
|
||||
if template == PROVIDER_CODEX:
|
||||
from .contrib.codex.agent_provider import CodexAgentProvider
|
||||
return CodexAgentProvider()
|
||||
if template == PROVIDER_PI:
|
||||
from .contrib.pi.agent_provider import PiAgentProvider
|
||||
return PiAgentProvider()
|
||||
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||
|
||||
|
||||
@@ -194,32 +336,49 @@ def runtime_for(template: str) -> AgentProviderRuntime:
|
||||
return get_provider(template).runtime
|
||||
|
||||
|
||||
def agent_provision_plan(
|
||||
def build_agent_provision_plan(
|
||||
*,
|
||||
template: str,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
instance_name: str,
|
||||
prompt_file: Path,
|
||||
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 = "",
|
||||
label: str = "",
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
"""Back-compat shim — `prepare` callers stay the same; the work
|
||||
now lives on the provider plugin."""
|
||||
return get_provider(template).provision_plan(
|
||||
dockerfile=dockerfile,
|
||||
state_dir=state_dir,
|
||||
guest_home=guest_home,
|
||||
instance_name=instance_name,
|
||||
prompt_file=prompt_file,
|
||||
guest_env=guest_env,
|
||||
auth_token=auth_token,
|
||||
forward_host_credentials=forward_host_credentials,
|
||||
host_env=host_env,
|
||||
trusted_project_path=trusted_project_path,
|
||||
label=label,
|
||||
color=color,
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
|
||||
def provider_startup_args(
|
||||
provider_settings: dict[str, object] | None,
|
||||
) -> tuple[str, ...]:
|
||||
raw = (provider_settings or {}).get("startup_args", ())
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
return tuple(arg for arg in raw if isinstance(arg, str))
|
||||
|
||||
|
||||
def prompt_args(
|
||||
prompt_mode: PromptMode,
|
||||
prompt_path: str | None,
|
||||
@@ -231,7 +390,11 @@ def prompt_args(
|
||||
if prompt_mode == "append_file":
|
||||
return ["--append-system-prompt-file", prompt_path]
|
||||
if prompt_mode == "read_prompt_file":
|
||||
if argv and "resume" in argv:
|
||||
if argv and ("resume" in argv or "remote-control" in argv):
|
||||
return []
|
||||
return [f"Read and follow the instructions in {prompt_path}."]
|
||||
if prompt_mode == "print_read_prompt_file":
|
||||
return ["-p", f"Read and follow the instructions in {prompt_path}."]
|
||||
if prompt_mode == "append_system_prompt":
|
||||
return ["--append-system-prompt", prompt_path]
|
||||
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
|
||||
|
||||
+195
-68
@@ -24,14 +24,16 @@ backend exposes five methods:
|
||||
enough metadata for callers (CLI `list active`, dashboard
|
||||
agents pane) to render a row.
|
||||
|
||||
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 `--backend` on `start` or BOT_BOTTLE_BACKEND
|
||||
(env var). When neither is set, compatible macOS hosts default to
|
||||
`macos-container`; other hosts default to `smolmachines`. Per PRD 0003
|
||||
the manifest does not carry a backend field; the host picks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager
|
||||
@@ -39,14 +41,15 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..manifest import GitEntry, Manifest
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ..supervise import SupervisePlan
|
||||
from ..util import expand_tilde
|
||||
from ..workspace import WorkspacePlan
|
||||
from ..env import resolve_env, ResolvedEnv
|
||||
from ..workspace import WorkspacePlan, workspace_plan
|
||||
from .print_util import print_multi, visible_agent_env_names
|
||||
from .util import host_skill_dir
|
||||
|
||||
@@ -58,7 +61,7 @@ class BottleSpec:
|
||||
Resolved values (image names, container name, scratch paths, runsc
|
||||
availability) live on the plan, not the spec."""
|
||||
|
||||
manifest: Manifest
|
||||
manifest: ManifestIndex
|
||||
agent_name: str
|
||||
copy_cwd: bool
|
||||
user_cwd: str
|
||||
@@ -67,6 +70,8 @@ class BottleSpec:
|
||||
# (`cli.py resume <identity>`) sets this to continue an existing
|
||||
# bottle's state. Empty string for a fresh `start`.
|
||||
identity: str = ""
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -75,21 +80,41 @@ class BottlePlan(ABC):
|
||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||
|
||||
spec: BottleSpec
|
||||
manifest: Manifest
|
||||
stage_dir: Path
|
||||
guest_home: str
|
||||
git_gate_plan: GitGatePlan
|
||||
|
||||
@property
|
||||
def guest_home(self) -> str:
|
||||
return self.agent_provision.guest_home
|
||||
|
||||
@property
|
||||
def git_gate_insteadof_host(self) -> str:
|
||||
"""Host (and optional port) used in git-gate insteadOf URLs.
|
||||
Docker uses the compose-network DNS alias; smolmachines
|
||||
overrides with a loopback IP:port since TSI has no DNS."""
|
||||
return "git-gate"
|
||||
|
||||
@property
|
||||
def git_gate_insteadof_scheme(self) -> str:
|
||||
"""URL scheme for git-gate insteadOf rewrites. 'git' for
|
||||
Docker (git daemon); 'http' for smolmachines (HTTP proxy
|
||||
over a published host port)."""
|
||||
return "git"
|
||||
egress_plan: EgressPlan
|
||||
supervise_plan: SupervisePlan | None
|
||||
agent_provision: AgentProvisionPlan
|
||||
workspace_plan: WorkspacePlan
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
@property
|
||||
def workspace_plan(self) -> WorkspacePlan:
|
||||
return workspace_plan(self.spec, guest_home=self.guest_home)
|
||||
|
||||
def print(self) -> 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)
|
||||
manifest = self.manifest
|
||||
agent = manifest.agent
|
||||
bottle = manifest.bottle
|
||||
|
||||
env_names = visible_agent_env_names(
|
||||
sorted(
|
||||
@@ -106,7 +131,7 @@ class BottlePlan(ABC):
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
identity = manifest.git_identity_summary()
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
|
||||
@@ -166,7 +191,7 @@ class ActiveAgent:
|
||||
of sidecar daemons currently up for this bottle (`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-
|
||||
`_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active-
|
||||
list rendering to disambiguate and by the dashboard's
|
||||
re-attach path."""
|
||||
|
||||
@@ -175,6 +200,8 @@ class ActiveAgent:
|
||||
agent_name: str # from metadata.json; "?" if missing
|
||||
started_at: str # ISO 8601 from metadata.json; "" if missing
|
||||
services: tuple[str, ...] # alphabetical
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
|
||||
|
||||
class Bottle(ABC):
|
||||
@@ -245,27 +272,101 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
|
||||
name: str
|
||||
|
||||
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
||||
"""Template method: run cross-backend host-side validation, then
|
||||
delegate to the subclass's `_resolve_plan` for the
|
||||
backend-specific resolution (names, scratch files, etc.). The
|
||||
validation step is enforced here so a future backend cannot
|
||||
accidentally skip it. No remote/runtime resources are created."""
|
||||
self._validate(spec)
|
||||
return self._resolve_plan(spec, stage_dir=stage_dir)
|
||||
from .resolve_common import (
|
||||
merge_provision_env_vars,
|
||||
mint_slug,
|
||||
prepare_agent_state_dir,
|
||||
prepare_egress,
|
||||
prepare_git_gate,
|
||||
prepare_supervise,
|
||||
resolve_manifest_dockerfile,
|
||||
write_launch_metadata,
|
||||
)
|
||||
|
||||
def _validate(self, spec: BottleSpec) -> None:
|
||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
||||
the named skills are present on the host, and every git
|
||||
IdentityFile resolves. Subclasses with additional preconditions
|
||||
should override and call `super()._validate(spec)` first."""
|
||||
manifest = spec.manifest
|
||||
manifest.require_agent(spec.agent_name)
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
self._validate_skills(agent.skills)
|
||||
self._validate_git_entries(bottle.git)
|
||||
self._validate_agent_provider_dockerfile(spec)
|
||||
manifest = self._validate(spec)
|
||||
|
||||
self._preflight()
|
||||
|
||||
manifest_bottle = manifest.bottle
|
||||
manifest_agent_provider = manifest_bottle.agent_provider
|
||||
agent_provider = get_provider(manifest_agent_provider.template)
|
||||
resolved_env = resolve_env(manifest)
|
||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||
|
||||
slug = mint_slug(spec)
|
||||
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
|
||||
|
||||
# Manifest may override the Dockerfile per-bottle; otherwise fall
|
||||
# back to the provider plugin's bundled Dockerfile (next to its
|
||||
# agent_provider.py module).
|
||||
if manifest_agent_provider.dockerfile:
|
||||
agent_dockerfile_path = resolve_manifest_dockerfile(
|
||||
manifest_agent_provider.dockerfile, spec,
|
||||
)
|
||||
else:
|
||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||
|
||||
agent_provision_plan = build_agent_provision_plan(
|
||||
template=manifest_agent_provider.template,
|
||||
dockerfile=agent_dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
instance_name=f"bot-bottle-{slug}",
|
||||
prompt_file=prompt_file,
|
||||
guest_env=self._build_guest_env(resolved_env),
|
||||
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
|
||||
auth_token=manifest_agent_provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace.workdir,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
provider_settings=manifest_agent_provider.settings,
|
||||
)
|
||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||
|
||||
return self._resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
def _preflight(self) -> None:
|
||||
"""
|
||||
tasks to do before resolving a plan
|
||||
"""
|
||||
pass
|
||||
|
||||
def _validate(self, spec: BottleSpec) -> Manifest:
|
||||
"""Cross-backend pre-launch checks. Parses the selected agent and
|
||||
its bottle (raising ManifestError on invalid content), confirms
|
||||
skills are present on the host, and every git IdentityFile resolves.
|
||||
|
||||
Returns the loaded Manifest for the selected agent. Subclasses with
|
||||
additional preconditions should override and call
|
||||
`super()._validate(spec)` first."""
|
||||
manifest = spec.manifest.load_for_agent(spec.agent_name)
|
||||
self._validate_skills(manifest.agent.skills)
|
||||
self._validate_agent_provider_dockerfile(spec, manifest)
|
||||
return manifest
|
||||
|
||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||
"""Each named skill must be a directory under the host's
|
||||
@@ -279,18 +380,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
f"Create it under ~/.claude/skills/, then re-run."
|
||||
)
|
||||
|
||||
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
|
||||
"""Each entry's IdentityFile must exist on the host (after
|
||||
expanding leading ~) — the git-gate copies it in at start time
|
||||
to authenticate the upstream push (PRD 0008). Shape is already
|
||||
enforced by Manifest validation; this only checks presence."""
|
||||
for entry in entries:
|
||||
key = expand_tilde(entry.IdentityFile)
|
||||
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)
|
||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
||||
bottle = manifest.bottle
|
||||
dockerfile = bottle.agent_provider.dockerfile
|
||||
if not dockerfile:
|
||||
return
|
||||
@@ -300,14 +391,26 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
if not path.is_file():
|
||||
die(
|
||||
f"agent_provider.dockerfile for bottle "
|
||||
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
||||
f"'{manifest.agent.bottle}' not found: {path}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||
def _resolve_plan(self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
stage_dir: Path) -> PlanT:
|
||||
"""Backend-specific plan resolution: image/container names,
|
||||
env-file, prompt-file, proxy plan, runtime detection. Called by
|
||||
`prepare` after `_validate` succeeds."""
|
||||
`prepare` after `_validate` succeeds. Instance name, image,
|
||||
prompt file, Dockerfile path, and guest home all live on
|
||||
`agent_provision_plan` — the source of truth."""
|
||||
|
||||
@abstractmethod
|
||||
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||
@@ -339,35 +442,42 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||
intercepted without per-tool reconfiguration."""
|
||||
provider = get_provider(plan.agent_provision.template)
|
||||
self.provision_ca(plan, bottle)
|
||||
provider.provision_ca(bottle, plan)
|
||||
prompt_path = provider.provision_prompt(plan, bottle)
|
||||
provider.provision(plan, bottle)
|
||||
provider.provision_skills(plan, bottle)
|
||||
self.provision_workspace(plan, bottle)
|
||||
self.provision_git(plan, bottle)
|
||||
provider.provision_git(bottle, plan)
|
||||
provider.provision_supervise_mcp(
|
||||
plan, bottle, self.supervise_mcp_url(plan),
|
||||
)
|
||||
return prompt_path
|
||||
|
||||
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||
"""Install the per-bottle CA into the agent's trust store so
|
||||
the agent trusts the bumped CONNECT cert egress presents.
|
||||
Default impl is a no-op so
|
||||
backends that don't yet support TLS interception (every backend
|
||||
except Docker today) aren't forced to implement it. The Docker
|
||||
backend overrides to docker-cp the cert in and run
|
||||
`update-ca-certificates`."""
|
||||
|
||||
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> 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."""
|
||||
"""Copy the operator workspace into the running bottle.
|
||||
|
||||
@abstractmethod
|
||||
def provision_git(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||
"""Copy the host's cwd `.git` directory into the running
|
||||
bottle if the user requested --cwd. No-op otherwise."""
|
||||
This is the only supported workspace-provisioning path: Docker
|
||||
does not build a derived image containing the current
|
||||
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 = shlex.quote(workspace.guest_path)
|
||||
guest_parent = shlex.quote(guest_parent)
|
||||
owner = shlex.quote(workspace.owner)
|
||||
mode = shlex.quote(workspace.mode)
|
||||
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
||||
bottle.exec(
|
||||
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
|
||||
user="root",
|
||||
)
|
||||
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
||||
bottle.exec(
|
||||
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
def supervise_mcp_url(self, plan: PlanT) -> str:
|
||||
"""Return the agent-side URL of the per-bottle supervise
|
||||
@@ -411,8 +521,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
# 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
|
||||
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
|
||||
# Freezer is imported after the backend classes for the same reason:
|
||||
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
||||
# defined first.
|
||||
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||
@@ -421,6 +537,7 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402
|
||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||
"docker": DockerBottleBackend(),
|
||||
"macos-container": MacosContainerBottleBackend(),
|
||||
"smolmachines": SmolmachinesBottleBackend(),
|
||||
}
|
||||
|
||||
@@ -433,17 +550,24 @@ def get_bottle_backend(
|
||||
`name` precedence:
|
||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
||||
2. BOT_BOTTLE_BACKEND env var
|
||||
3. default `docker`
|
||||
3. `macos-container` on compatible macOS hosts
|
||||
4. default `smolmachines`
|
||||
|
||||
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"
|
||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
||||
if resolved not in _BACKENDS:
|
||||
known = ", ".join(sorted(_BACKENDS))
|
||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||
return _BACKENDS[resolved]
|
||||
|
||||
|
||||
def _default_backend_name() -> str:
|
||||
if has_backend("macos-container"):
|
||||
return "macos-container"
|
||||
return "smolmachines"
|
||||
|
||||
|
||||
def known_backend_names() -> tuple[str, ...]:
|
||||
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
||||
argparse (`--backend` choices) and the dashboard's backend
|
||||
@@ -493,9 +617,12 @@ __all__ = [
|
||||
"BottleCleanupPlan",
|
||||
"BottlePlan",
|
||||
"BottleSpec",
|
||||
"CommitCancelled",
|
||||
"ExecResult",
|
||||
"Freezer",
|
||||
"enumerate_active_agents",
|
||||
"get_bottle_backend",
|
||||
"get_freezer",
|
||||
"has_backend",
|
||||
"known_backend_names",
|
||||
]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
This module is a thin façade. The real work lives in four 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
|
||||
- resolve_plan.py — Docker-specific resolution into a DockerBottlePlan
|
||||
- launch.py — bring-up + teardown context manager
|
||||
- cleanup.py — orphan enumeration + removal
|
||||
- enumerate.py — active-agent listing
|
||||
|
||||
The base class's `prepare` template runs cross-backend host-side
|
||||
validation before calling `_resolve_plan` here.
|
||||
@@ -25,21 +25,23 @@ from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
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 resolve_plan as _resolve_plan
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .provision import ca as _ca
|
||||
from .provision import git as _git
|
||||
|
||||
|
||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
|
||||
(default)."""
|
||||
when set to `docker`; retained as a legacy/example backend."""
|
||||
|
||||
name = "docker"
|
||||
|
||||
@@ -52,20 +54,42 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
launch."""
|
||||
return shutil.which("docker") is not None
|
||||
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
|
||||
def _preflight(self) -> None:
|
||||
_resolve_plan.preflight()
|
||||
|
||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return _resolve_plan.build_guest_env(resolved_env)
|
||||
|
||||
def _resolve_plan(
|
||||
self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
stage_dir: Path,
|
||||
) -> DockerBottlePlan:
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||
_ca.provision_ca(plan, bottle)
|
||||
|
||||
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||
_git.provision_git(plan, bottle)
|
||||
|
||||
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||
"""Docker bottles reach the supervise sidecar via the
|
||||
compose-network alias `supervise:9100`. No per-bottle URL
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import cast
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from ..terminal import exec_shell_script
|
||||
|
||||
|
||||
class DockerBottle(Bottle):
|
||||
@@ -22,15 +23,20 @@ class DockerBottle(Bottle):
|
||||
*,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: PromptMode = "append_file",
|
||||
agent_provider_template: str = "claude",
|
||||
terminal_title: str = "",
|
||||
terminal_color: str = "",
|
||||
agent_workdir: str = "/home/node",
|
||||
):
|
||||
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.terminal_title = terminal_title
|
||||
self.terminal_color = terminal_color
|
||||
self.agent_provider_template = agent_provider_template
|
||||
self.agent_workdir = agent_workdir
|
||||
self._closed = False
|
||||
|
||||
def agent_argv(
|
||||
@@ -43,13 +49,17 @@ class DockerBottle(Bottle):
|
||||
cmd = ["docker", "exec"]
|
||||
if tty:
|
||||
cmd.append("-it")
|
||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
||||
cmd.extend(["-w", self.agent_workdir])
|
||||
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||
return cmd
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
return subprocess.run(
|
||||
self.agent_argv(argv, tty=tty), check=False,
|
||||
).returncode
|
||||
agent_argv = self.agent_argv(argv, tty=tty)
|
||||
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||
if script is None:
|
||||
return subprocess.run(agent_argv, check=False).returncode
|
||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
# Pipe via stdin to `sh -s` so the caller never has to worry
|
||||
|
||||
@@ -22,25 +22,32 @@ class DockerBottlePlan(BottlePlan):
|
||||
`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
|
||||
use_runsc: bool
|
||||
|
||||
@property
|
||||
def container_name(self) -> str:
|
||||
return self.agent_provision.instance_name
|
||||
|
||||
@property
|
||||
def image(self) -> str:
|
||||
return self.agent_provision.image
|
||||
|
||||
@property
|
||||
def dockerfile_path(self) -> str:
|
||||
"""Absolute path to the Dockerfile that builds `image`. Sourced
|
||||
from the agent provision plan — the manifest may override per
|
||||
bottle; otherwise the provider plugin's bundled Dockerfile."""
|
||||
return self.agent_provision.dockerfile
|
||||
|
||||
@property
|
||||
def prompt_file(self) -> Path:
|
||||
return self.agent_provision.prompt_file
|
||||
|
||||
@property
|
||||
def agent_command(self) -> str:
|
||||
return self.agent_provision.command
|
||||
|
||||
@@ -32,10 +32,10 @@ from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import get_provider
|
||||
from ...log import info, warn
|
||||
from .bottle_state import (
|
||||
from ...bottle_state import (
|
||||
mark_preserved,
|
||||
per_bottle_dockerfile,
|
||||
transcript_snapshot_dir,
|
||||
@@ -93,11 +93,11 @@ def fetch_current_dockerfile(slug: str) -> str:
|
||||
override = per_bottle_dockerfile(slug)
|
||||
if override is not None:
|
||||
return override
|
||||
repo_dockerfile = _repo_dockerfile_path()
|
||||
repo_dockerfile = get_provider("claude").dockerfile
|
||||
if repo_dockerfile.is_file():
|
||||
return repo_dockerfile.read_text()
|
||||
raise CapabilityApplyError(
|
||||
f"no per-bottle Dockerfile for {slug} and no repo Dockerfile at "
|
||||
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
|
||||
f"{repo_dockerfile}"
|
||||
)
|
||||
|
||||
@@ -125,13 +125,6 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
||||
# --- Internals -------------------------------------------------------------
|
||||
|
||||
|
||||
def _repo_dockerfile_path() -> Path:
|
||||
"""Path to the repo's Claude Dockerfile (one dir above this module's
|
||||
package root). Resolved at call time so the path is correct
|
||||
regardless of where this module is imported from."""
|
||||
# bot_bottle/backend/docker/capability_apply.py -> repo root
|
||||
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||
|
||||
|
||||
def snapshot_transcript(slug: str) -> None:
|
||||
"""`docker cp` /home/node/.claude out of the agent container into
|
||||
|
||||
@@ -31,7 +31,7 @@ from ... import supervise as _supervise
|
||||
from ...log import info, warn
|
||||
from . import util as docker_mod
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_state import bottle_state_dir, is_preserved
|
||||
from ...bottle_state import bottle_state_dir, is_preserved
|
||||
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ from typing import Any
|
||||
from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
@@ -134,9 +136,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
ep = plan.egress_plan
|
||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
||||
if ep.routes:
|
||||
volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
|
||||
for token_env in sorted(ep.token_env_map.keys()):
|
||||
env.append(token_env)
|
||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
||||
env.extend(egress_sidecar_env_entries(ep))
|
||||
|
||||
# --- git-gate -----------------------------------------------------
|
||||
gp = plan.git_gate_plan
|
||||
@@ -220,9 +221,10 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
# never lands on argv or in the compose file.
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": plan.runtime_image,
|
||||
"image": plan.image,
|
||||
"container_name": plan.container_name,
|
||||
"command": ["sleep", "infinity"],
|
||||
"networks": {"internal": None},
|
||||
@@ -230,8 +232,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
}
|
||||
if plan.use_runsc:
|
||||
service["runtime"] = "runsc"
|
||||
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
|
||||
service["env_file"] = [str(plan.env_file)]
|
||||
|
||||
volumes: list[dict[str, Any]] = []
|
||||
if plan.supervise_plan is not None:
|
||||
|
||||
@@ -1,74 +1,21 @@
|
||||
"""Host-side helper to apply a routes.yaml change to a running
|
||||
egress sidecar (PRD 0014 retargeted by PRD 0017 chunk 3, PRD 0053).
|
||||
"""Host-side helper for egress sidecar inspection and live updates.
|
||||
|
||||
Used by the supervise dashboard when the operator approves an
|
||||
egress-block proposal. Fetches current routes.yaml, validates,
|
||||
writes into the sidecar, then SIGHUPs to reload.
|
||||
The approve path uses this module to validate a proposed routes file,
|
||||
write it to the bottle's live egress state dir, and signal the sidecar
|
||||
bundle so the mitmproxy addon reloads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
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 ...log import warn
|
||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
|
||||
|
||||
def _render_routes_payload(routes_list: list[dict[str, object]]) -> str:
|
||||
"""Render a list-of-dicts routes payload as YAML matching the
|
||||
shape `egress_render_routes` produces."""
|
||||
if not routes_list:
|
||||
return "routes: []\n"
|
||||
lines: list[str] = ["routes:"]
|
||||
for entry in routes_list:
|
||||
host = str(entry.get("host", ""))
|
||||
lines.append(f' - host: "{host}"')
|
||||
auth_scheme = entry.get("auth_scheme")
|
||||
token_env = entry.get("token_env")
|
||||
if auth_scheme and token_env:
|
||||
lines.append(f' auth_scheme: "{auth_scheme}"')
|
||||
lines.append(f' token_env: "{token_env}"')
|
||||
matches_obj = entry.get("matches")
|
||||
if isinstance(matches_obj, list) and matches_obj:
|
||||
lines.append(" matches:")
|
||||
for match_entry in matches_obj:
|
||||
me = cast(dict[str, object], match_entry)
|
||||
first_key = True
|
||||
if "paths" in me:
|
||||
lines.append(" - paths:")
|
||||
first_key = False
|
||||
for pd in cast(list[dict[str, str]], me["paths"]):
|
||||
if "type" in pd:
|
||||
lines.append(f' - type: "{pd["type"]}"')
|
||||
lines.append(f' value: "{pd["value"]}"')
|
||||
else:
|
||||
lines.append(f' - value: "{pd["value"]}"')
|
||||
if "methods" in me:
|
||||
methods_str = ", ".join(
|
||||
f'"{m}"' for m in cast(list[str], me["methods"])
|
||||
)
|
||||
prefix = " - " if first_key else " "
|
||||
lines.append(f'{prefix}methods: [{methods_str}]')
|
||||
first_key = False
|
||||
if first_key:
|
||||
lines.append(" - {}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _egress_routes_host_path(slug: str) -> Path:
|
||||
return egress_state_dir(slug) / "egress_routes.yaml"
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def fetch_current_routes(slug: str) -> str:
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
r = subprocess.run(
|
||||
@@ -83,153 +30,31 @@ def fetch_current_routes(slug: str) -> str:
|
||||
return r.stdout
|
||||
|
||||
|
||||
def validate_routes_content(content: str) -> None:
|
||||
try:
|
||||
load_routes(content)
|
||||
except ValueError as e:
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
before = fetch_current_routes(slug)
|
||||
validate_routes_content(new_content)
|
||||
|
||||
target = _egress_routes_host_path(slug)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(new_content)
|
||||
target.chmod(0o644)
|
||||
sig = subprocess.run(
|
||||
["docker", "kill", "--signal", "HUP", container],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if sig.returncode != 0:
|
||||
raise EgressApplyError(
|
||||
f"failed to SIGHUP {container}: "
|
||||
f"{(sig.stderr or '').strip()}"
|
||||
class DockerEgressApplicator(EgressApplicator):
|
||||
def _signal_bundle_reload(self, slug: str) -> None:
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["docker", "kill", "--signal", "HUP", container],
|
||||
capture_output=True, text=True, check=False, env=os.environ,
|
||||
)
|
||||
|
||||
return before, new_content
|
||||
if result.returncode != 0:
|
||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
warn(
|
||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
||||
f"{last_error or 'docker kill failed'}"
|
||||
)
|
||||
raise EgressApplyError(
|
||||
f"could not reload egress bundle {container}: "
|
||||
f"{last_error or 'docker kill failed'}"
|
||||
)
|
||||
|
||||
|
||||
def _merge_single_route(
|
||||
current_yaml: str, new_route: dict[str, object],
|
||||
) -> str:
|
||||
"""Merge a single proposed route into the current routes.yaml.
|
||||
|
||||
- Host absent → append the route.
|
||||
- Host present → union the match paths (proposed ∪ existing).
|
||||
Auth is preserved from existing route.
|
||||
"""
|
||||
try:
|
||||
cfg = parse_yaml_subset(current_yaml)
|
||||
except YamlSubsetError as e:
|
||||
raise EgressApplyError(
|
||||
f"current routes.yaml is not valid YAML: {e}"
|
||||
) from e
|
||||
routes = cfg.get("routes")
|
||||
if not isinstance(routes, list):
|
||||
raise EgressApplyError(
|
||||
"current routes.yaml: 'routes' is not a list"
|
||||
)
|
||||
routes_typed = cast(list[object], routes)
|
||||
|
||||
new_host = str(new_route.get("host", "")).lower()
|
||||
if not new_host:
|
||||
raise EgressApplyError(
|
||||
"proposed route is missing 'host'"
|
||||
)
|
||||
|
||||
# Build proposed matches from the input
|
||||
proposed_matches = new_route.get("matches")
|
||||
if proposed_matches is None:
|
||||
# Accept legacy path_allowlist from agent proposals and convert
|
||||
proposed_paths = new_route.get("path_allowlist")
|
||||
if isinstance(proposed_paths, list) and proposed_paths:
|
||||
proposed_matches = [{"paths": [{"value": p} for p in proposed_paths]}]
|
||||
|
||||
for entry in routes_typed:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_typed = cast(dict[str, object], entry)
|
||||
if str(entry_typed.get("host", "")).lower() == new_host:
|
||||
# Merge matches: union path values from proposed into existing
|
||||
if isinstance(proposed_matches, list) and proposed_matches:
|
||||
existing_matches = entry_typed.get("matches")
|
||||
if not isinstance(existing_matches, list):
|
||||
existing_matches = []
|
||||
# Simple merge: collect all existing path values, add new ones
|
||||
existing_paths: set[str] = set()
|
||||
for me in existing_matches:
|
||||
me_typed = cast(dict[str, object], me) if isinstance(me, dict) else {}
|
||||
paths = me_typed.get("paths")
|
||||
if isinstance(paths, list):
|
||||
for p in paths:
|
||||
p_typed = cast(dict[str, object], p) if isinstance(p, dict) else {}
|
||||
val = p_typed.get("value")
|
||||
if isinstance(val, str):
|
||||
existing_paths.add(val)
|
||||
new_paths: list[str] = []
|
||||
for me in proposed_matches:
|
||||
me_typed = cast(dict[str, object], me) if isinstance(me, dict) else {}
|
||||
paths = me_typed.get("paths")
|
||||
if isinstance(paths, list):
|
||||
for p in paths:
|
||||
p_typed = cast(dict[str, object], p) if isinstance(p, dict) else {}
|
||||
val = p_typed.get("value")
|
||||
if isinstance(val, str) and val not in existing_paths:
|
||||
new_paths.append(val)
|
||||
existing_paths.add(val)
|
||||
if new_paths:
|
||||
existing_matches.append(
|
||||
{"paths": [{"value": p} for p in new_paths]}
|
||||
)
|
||||
entry_typed["matches"] = existing_matches
|
||||
break
|
||||
else:
|
||||
entry_typed: dict[str, object] = {"host": new_route.get("host")} # type: ignore
|
||||
if isinstance(proposed_matches, list) and proposed_matches:
|
||||
entry_typed["matches"] = proposed_matches
|
||||
auth = new_route.get("auth")
|
||||
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"): # type: ignore
|
||||
auth_typed = cast(dict[str, object], auth)
|
||||
existing_slots = sorted({
|
||||
str(r_entry.get("token_env", ""))
|
||||
for r_entry_obj in routes_typed
|
||||
if isinstance(r_entry_obj, dict)
|
||||
for r_entry in [cast(dict[str, object], r_entry_obj)]
|
||||
if r_entry.get("token_env")
|
||||
})
|
||||
next_idx = len(existing_slots)
|
||||
entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme")))
|
||||
entry_typed["token_env"] = f"EGRESS_TOKEN_{next_idx}"
|
||||
routes_typed.append(entry_typed)
|
||||
|
||||
return _render_routes_payload(cast(list[dict[str, object]], routes_typed))
|
||||
|
||||
|
||||
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
|
||||
try:
|
||||
proposed = json.loads(proposed_route_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise EgressApplyError(
|
||||
f"proposed route is not valid JSON: {e}"
|
||||
) from e
|
||||
if not isinstance(proposed, dict):
|
||||
raise EgressApplyError(
|
||||
"proposed route must be a JSON object"
|
||||
)
|
||||
current = fetch_current_routes(slug)
|
||||
merged = _merge_single_route(current, proposed)
|
||||
return apply_routes_change(slug, merged)
|
||||
applicator = DockerEgressApplicator()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DockerEgressApplicator",
|
||||
"EgressApplyError",
|
||||
"add_route",
|
||||
"apply_routes_change",
|
||||
"applicator",
|
||||
"fetch_current_routes",
|
||||
"validate_routes_content",
|
||||
]
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
|
||||
from .. import ActiveAgent
|
||||
from .bottle_state import read_metadata
|
||||
from ...bottle_state import read_metadata
|
||||
from .compose import compose_project_name, list_active_slugs
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ def enumerate_active() -> list[ActiveAgent]:
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=tuple(sorted(services)),
|
||||
label=metadata.label if metadata else "",
|
||||
color=metadata.color if metadata else "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""DockerFreezer — snapshot a Docker bottle via `docker commit`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
from .util import commit_container
|
||||
from ...log import info
|
||||
|
||||
|
||||
class DockerFreezer(Freezer):
|
||||
"""Freezes a Docker bottle by running `docker commit`."""
|
||||
|
||||
backend_name = "docker"
|
||||
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
container = f"bot-bottle-{agent.slug}"
|
||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
commit_container(container, image_tag)
|
||||
return image_tag
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(f"to export for migration: docker save {image_ref} -o {slug}.tar")
|
||||
@@ -4,8 +4,8 @@ PRD 0018 chunk 3: each instance is one `docker compose` project.
|
||||
|
||||
The flow is:
|
||||
|
||||
1. Build the agent's base + derived image (compose builds the
|
||||
sidecar images via the `build:` directive on first up).
|
||||
1. Build the agent image from the provider Dockerfile (compose
|
||||
builds the sidecar images via the `build:` directive on first up).
|
||||
2. Mint the per-bottle egress CA (chunk 2 writes it under
|
||||
state/<slug>/egress/).
|
||||
3. Populate the inner plans with launch-time fields so the
|
||||
@@ -15,8 +15,8 @@ The flow is:
|
||||
7. `docker compose up -d` (token + OAuth values flow into the
|
||||
compose subprocess env so `environment: [NAME]` bare-name
|
||||
entries inherit without rendering values into the file).
|
||||
8. Provision (CA install, prompt copy, skills, git, supervise
|
||||
config) — unchanged, uses `docker exec`.
|
||||
8. Provision (CA install, prompt copy, skills, workspace, git,
|
||||
supervise config) — unchanged, uses `docker exec` / `docker cp`.
|
||||
9. Yield a DockerBottle handle. `exec_agent` runs claude via
|
||||
`docker exec -it` exactly like the pre-compose world.
|
||||
|
||||
@@ -43,10 +43,11 @@ from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .bottle_state import (
|
||||
from ...bottle_state import (
|
||||
bottle_state_dir,
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from .compose import (
|
||||
bottle_plan_to_compose,
|
||||
@@ -75,7 +76,7 @@ def launch(
|
||||
Teardown on exit."""
|
||||
stack = ExitStack()
|
||||
|
||||
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
_bottle_for_revoke = plan.manifest.bottle
|
||||
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||
|
||||
def teardown() -> None:
|
||||
@@ -91,15 +92,21 @@ def launch(
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: agent image build. Sidecar images get built lazily by
|
||||
# `docker compose up` via the renderer's `build:` directives.
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
if plan.derived_image:
|
||||
docker_mod.build_image_with_cwd(
|
||||
plan.derived_image, plan.image, plan.workspace_plan
|
||||
# Step 1: agent image. Use a committed snapshot when one exists
|
||||
# and is present in the local daemon; otherwise build from the
|
||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||
# up` via the renderer's `build:` directives.
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
else:
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
|
||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||
@@ -179,6 +186,10 @@ def launch(
|
||||
None,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
agent_provider_template=plan.agent_provider_template,
|
||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
)
|
||||
bottle.prompt_path = provision(plan, bottle)
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Prepare step for the Docker bottle backend.
|
||||
|
||||
`resolve_plan` does all host-side resolution (image and container
|
||||
names, env-file, prompt-file, proxy plan, runtime detection) and
|
||||
returns a frozen DockerBottlePlan. No Docker resources are created;
|
||||
the only side effects are scratch files under `stage_dir` and a probe
|
||||
of `docker info`. Cross-backend host-side validation has already run
|
||||
via the base class's `prepare` template before this is called.
|
||||
"""
|
||||
|
||||
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 ...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 .bottle_state import (
|
||||
BottleMetadata,
|
||||
agent_state_dir,
|
||||
bottle_identity,
|
||||
clear_preserve_marker,
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
per_bottle_dockerfile,
|
||||
per_bottle_dockerfile_path,
|
||||
per_bottle_image_tag,
|
||||
supervise_state_dir,
|
||||
write_metadata,
|
||||
)
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
stage_dir: Path,
|
||||
) -> 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()
|
||||
|
||||
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 = "/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
|
||||
# agent in the same cwd don't collide on container/network
|
||||
# names); a `resume` passes the recorded identity in via
|
||||
# spec.identity to continue an existing bottle's state.
|
||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||
# Record the launch metadata so `cli.py resume <identity>` can
|
||||
# reconstruct the spec. Idempotent — re-writes on resume with a
|
||||
# refreshed started_at.
|
||||
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=f"bot-bottle-{slug}",
|
||||
backend="docker",
|
||||
))
|
||||
# Clear any leftover preserve marker from a prior capability-block
|
||||
# so this fresh launch can be cleaned up at session-end unless
|
||||
# the agent triggers another capability-block.
|
||||
clear_preserve_marker(slug)
|
||||
|
||||
# PRD 0016 capability-block: if a per-bottle Dockerfile has been
|
||||
# written (via apply_capability_change), the base image becomes
|
||||
# per_bottle_image_tag(slug) built from that file. --cwd still
|
||||
# layers a derived image on top.
|
||||
dockerfile_path = ""
|
||||
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)
|
||||
derived_image = ""
|
||||
runtime_image = image
|
||||
if spec.copy_cwd:
|
||||
derived_image = os.environ.get(
|
||||
"BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}"
|
||||
)
|
||||
runtime_image = derived_image
|
||||
|
||||
default_container = f"bot-bottle-{slug}"
|
||||
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
|
||||
container_name_pinned = bool(pinned_container)
|
||||
if container_name_pinned:
|
||||
container_name = pinned_container
|
||||
if docker_mod.container_exists(container_name):
|
||||
die(
|
||||
f"container '{container_name}' already exists "
|
||||
f"(pinned via BOT_BOTTLE_CONTAINER). "
|
||||
f"Remove it with 'docker rm -f {container_name}' or unset the override."
|
||||
)
|
||||
else:
|
||||
container_name = ""
|
||||
for candidate in docker_mod.container_name_candidates(default_container):
|
||||
if not docker_mod.container_exists(candidate):
|
||||
container_name = candidate
|
||||
break
|
||||
if not container_name:
|
||||
die(
|
||||
f"could not find a free container name after "
|
||||
f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; "
|
||||
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."
|
||||
)
|
||||
|
||||
# PRD 0018 chunk 2: prepare-time scratch files live under
|
||||
# ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose
|
||||
# bind-mounts can point at stable paths. The state subdirs are
|
||||
# cleaned up by start.py's session-end teardown unless something
|
||||
# explicitly preserves the state dir (capability-block, crash).
|
||||
agent_dir = agent_state_dir(slug)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
env_file = agent_dir / "agent.env"
|
||||
prompt_file = agent_dir / "prompt.txt"
|
||||
prompt_file.write_text("")
|
||||
prompt_file.chmod(0o600)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
# Current Dockerfile for the agent image. Read from the repo
|
||||
# root; for `--cwd` derived images the base Dockerfile is what
|
||||
# the agent should propose changes against (the derived layer
|
||||
# is just a workspace copy).
|
||||
# (routes.yaml used to land here too but PRD 0017 chunk 3
|
||||
# moved it 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(encoding="utf-8")
|
||||
if supervise_dockerfile_path.is_file()
|
||||
else ""
|
||||
)
|
||||
supervise_dir = supervise_state_dir(slug)
|
||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||
supervise_plan = supervise.prepare(
|
||||
slug, supervise_dir,
|
||||
dockerfile_content=dockerfile_content,
|
||||
)
|
||||
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage_dir,
|
||||
guest_home=guest_home,
|
||||
slug=slug,
|
||||
container_name=container_name,
|
||||
container_name_pinned=container_name_pinned,
|
||||
image=image,
|
||||
derived_image=derived_image,
|
||||
runtime_image=runtime_image,
|
||||
dockerfile_path=dockerfile_path,
|
||||
env_file=env_file,
|
||||
forwarded_env=forwarded_env,
|
||||
prompt_file=prompt_file,
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=use_runsc,
|
||||
agent_provision=agent_provision,
|
||||
workspace_plan=workspace_plan,
|
||||
)
|
||||
|
||||
|
||||
def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
|
||||
"""Serialize the literal portion of a ResolvedEnv into docker's
|
||||
`--env-file` syntax (NAME=VALUE per line, mode 600 since the file
|
||||
may carry verbatim values from the manifest). Forwarded names ride
|
||||
on the plan as a structured tuple instead."""
|
||||
env_lines: list[str] = []
|
||||
for name, value in resolved.literals.items():
|
||||
if "\n" in value:
|
||||
die(
|
||||
f"env entry {name} (literal) contains a newline; "
|
||||
f"docker --env-file cannot represent multi-line values."
|
||||
)
|
||||
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)
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||
declarative provision-plan apply, supervise MCP registration) live on
|
||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||
left in this subpackage handle only the steps that are
|
||||
backend-specific:
|
||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
|
||||
provisioning also moved to the AgentProvider ABC (with Debian/node
|
||||
defaults); user plugins override them for non-standard images.
|
||||
|
||||
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||
No modules remain in this subpackage — the directory is kept so that
|
||||
existing imports of `from .provision import ...` don't need updating
|
||||
if new backend-specific provisioners are added later.
|
||||
"""
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Install the per-bottle egress MITM CA into the agent container's
|
||||
trust store.
|
||||
|
||||
By the time this provisioner runs, `egress_tls_init` has generated
|
||||
the egress CA and the path is re-bound into `plan.egress_plan`.
|
||||
|
||||
Cert lands on Debian's standard source path
|
||||
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
|
||||
rebuilds `/etc/ssl/certs/ca-certificates.crt`, which is what curl,
|
||||
Python `ssl`, and OpenSSL-based tools all read by default. The env
|
||||
trio set on the agent's `docker run` covers Node
|
||||
(`NODE_EXTRA_CA_CERTS`) and Python `requests` /
|
||||
`SSL_CERT_FILE`-honoring libraries that don't load the system
|
||||
bundle.
|
||||
|
||||
The fingerprint is computed via stdlib (`ssl.PEM_cert_to_DER_cert`
|
||||
+ `hashlib.sha256`) and logged once to stderr. The private key
|
||||
stays on the host (under `stage_dir`) until teardown wipes the
|
||||
stage dir; nothing in the agent ever sees it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import Bottle
|
||||
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> 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."""
|
||||
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
||||
|
||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||
bottle.exec(
|
||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||
user="root",
|
||||
)
|
||||
|
||||
log_ca_fingerprint(cert_host_path, label)
|
||||
@@ -1,106 +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 shlex
|
||||
|
||||
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||
from ....log import info
|
||||
from ... import Bottle
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
|
||||
|
||||
def provision_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||
"""Set up git inside the bottle. Runs all three subcases; each
|
||||
no-ops when its condition isn't met."""
|
||||
_provision_cwd_git(plan, bottle)
|
||||
_provision_git_gate_config(plan, bottle)
|
||||
_provision_git_user(plan, bottle)
|
||||
|
||||
|
||||
def _provision_cwd_git(plan: DockerBottlePlan, bottle: Bottle) -> 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
|
||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||
host_git = str(workspace.host_path / ".git")
|
||||
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||
bottle.cp_in(host_git, guest_workspace_git)
|
||||
bottle.exec(
|
||||
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
def _provision_git_gate_config(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||
"""Write ~/.gitconfig in the bottle with the git-gate
|
||||
insteadOf rules. No-op when the bottle has no `git` entries."""
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not manifest_bottle.git:
|
||||
return
|
||||
container_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||
|
||||
content = git_gate_render_gitconfig(manifest_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(manifest_bottle.git)} insteadOf rule(s)")
|
||||
bottle.cp_in(str(config_file), container_gitconfig)
|
||||
bottle.exec(
|
||||
f"chown node:node {shlex.quote(container_gitconfig)} && "
|
||||
f"chmod 644 {shlex.quote(container_gitconfig)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
def _provision_git_user(plan: DockerBottlePlan, bottle: Bottle) -> 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."""
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
gu = manifest_bottle.git_user
|
||||
if gu.is_empty():
|
||||
return
|
||||
if gu.name:
|
||||
info(f"git config --global user.name = {gu.name!r}")
|
||||
bottle.exec(
|
||||
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||
user="node",
|
||||
)
|
||||
if gu.email:
|
||||
info(f"git config --global user.email = {gu.email!r}")
|
||||
bottle.exec(
|
||||
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||
user="node",
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Prepare step for the Docker bottle backend.
|
||||
|
||||
`resolve_plan` does all host-side resolution (image and container
|
||||
names, prompt-file, proxy plan, runtime detection) and returns a
|
||||
frozen DockerBottlePlan. No Docker resources are created; the only
|
||||
side effects are scratch files under `stage_dir` and a probe of
|
||||
`docker info`. Cross-backend host-side validation has already run
|
||||
via the base class's `prepare` template before this is called.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from . import util as docker_mod
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .. import BottleSpec
|
||||
from ...env import ResolvedEnv
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...manifest import Manifest
|
||||
from ...supervise import SupervisePlan
|
||||
from ...git_gate import GitGatePlan
|
||||
|
||||
def preflight() -> None:
|
||||
docker_mod.require_docker()
|
||||
|
||||
|
||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return dict(resolved_env.literals)
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
git_gate_plan: GitGatePlan,
|
||||
stage_dir: Path,
|
||||
) -> 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 specific setup ====
|
||||
use_runsc = docker_mod.runsc_available()
|
||||
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
forwarded_env=dict(resolved_env.forwarded),
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=use_runsc,
|
||||
agent_provision=agent_provision_plan,
|
||||
)
|
||||
@@ -7,11 +7,10 @@ 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
|
||||
# from ...workspace import WorkspacePlan
|
||||
|
||||
|
||||
# Cap on the suffix the container-name conflict logic will try before
|
||||
@@ -118,39 +117,54 @@ 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."""
|
||||
import os
|
||||
# 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."""
|
||||
# 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,
|
||||
# )
|
||||
|
||||
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 commit_container(container_name: str, image_tag: str) -> None:
|
||||
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
||||
running container's filesystem state as a local Docker image."""
|
||||
result = subprocess.run(
|
||||
["docker", "commit", container_name, image_tag],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
info(f"committed {container_name!r} → {image_tag!r}")
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shared base class for host-side egress apply across backends.
|
||||
|
||||
Each backend subclasses EgressApplicator and overrides _signal_bundle_reload
|
||||
with the backend-specific kill command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from ..bottle_state import egress_state_dir
|
||||
from ..egress import EGRESS_ROUTES_FILENAME
|
||||
from ..egress_addon_core import LOG_OFF, load_config
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class EgressApplicator(ABC):
|
||||
def apply_routes_change(self, slug: str, content: str) -> tuple[str, str]:
|
||||
"""Persist `content` to the live routes file and reload egress."""
|
||||
self.validate_routes_content(content)
|
||||
routes_path = self._routes_path(slug)
|
||||
routes_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
before = routes_path.read_text(encoding="utf-8") if routes_path.exists() else ""
|
||||
routes_path.write_text(content, encoding="utf-8")
|
||||
routes_path.chmod(0o600)
|
||||
self._signal_bundle_reload(slug)
|
||||
return before, content
|
||||
|
||||
@staticmethod
|
||||
def validate_routes_content(content: str) -> None:
|
||||
try:
|
||||
config = load_config(content)
|
||||
except ValueError as e:
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
if config.log != LOG_OFF:
|
||||
raise EgressApplyError(
|
||||
"proposed routes.yaml must not change egress logging"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _routes_path(slug: str) -> Path:
|
||||
return egress_state_dir(slug) / EGRESS_ROUTES_FILENAME
|
||||
|
||||
@abstractmethod
|
||||
def _signal_bundle_reload(self, slug: str) -> None: ...
|
||||
|
||||
|
||||
__all__ = ["EgressApplicator", "EgressApplyError"]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Freezer — snapshot a running bottle to a resumable artifact.
|
||||
|
||||
Follows the same pattern as BottleBackend: a shared base class with
|
||||
common post-freeze steps (write committed-image path, mark preserved,
|
||||
print resume hint) and backend-specific subclasses in their respective
|
||||
backend directories.
|
||||
|
||||
Entry points:
|
||||
Freezer.commit(agent) — freeze by ActiveAgent
|
||||
Freezer.commit_slug(slug) — convenience wrapper for cmd_commit
|
||||
get_freezer(backend_name) — factory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from . import ActiveAgent
|
||||
from ..bottle_state import mark_preserved, write_committed_image
|
||||
from ..log import die, info
|
||||
|
||||
|
||||
class CommitCancelled(Exception):
|
||||
"""Raised by Freezer._freeze when the user declines a confirmation prompt."""
|
||||
|
||||
|
||||
class Freezer(ABC):
|
||||
"""Freezes a running bottle to a resumable artifact.
|
||||
|
||||
The base class owns the shared post-commit steps:
|
||||
- write_committed_image — records the artifact path in per-bottle state
|
||||
- mark_preserved — prevents teardown from removing the state dir
|
||||
- resume hint — printed to stderr after the snapshot
|
||||
|
||||
Subclasses implement _freeze with the backend-specific snapshot
|
||||
operation and optionally override _export_hint for migration hints.
|
||||
"""
|
||||
|
||||
backend_name: str
|
||||
|
||||
def commit(self, agent: ActiveAgent) -> None:
|
||||
"""Freeze the bottle for `agent` to a resumable artifact.
|
||||
|
||||
Calls _freeze for the backend-specific snapshot, then writes the
|
||||
committed image reference to per-bottle state and marks the bottle
|
||||
preserved so the next `./cli.py resume` boots from the snapshot.
|
||||
|
||||
Raises CommitCancelled if the user declines an interactive
|
||||
confirmation prompt (e.g. the macos-container stop prompt).
|
||||
"""
|
||||
image_ref = self._freeze(agent)
|
||||
write_committed_image(agent.slug, image_ref)
|
||||
mark_preserved(agent.slug)
|
||||
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}")
|
||||
self._export_hint(agent.slug, image_ref)
|
||||
|
||||
@abstractmethod
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
"""Backend-specific snapshot. Returns the image tag or artifact path
|
||||
stored by write_committed_image. Raises CommitCancelled if the user
|
||||
declines a stop-confirmation prompt."""
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
"""Optionally print an export-for-migration hint after committing.
|
||||
Overridden by backends that provide a meaningful export command."""
|
||||
|
||||
def commit_slug(self, slug: str) -> None:
|
||||
"""Convenience entry for cmd_commit when only a slug is available."""
|
||||
from ..bottle_state import read_metadata
|
||||
metadata = read_metadata(slug)
|
||||
agent = ActiveAgent(
|
||||
backend_name=self.backend_name,
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=(),
|
||||
)
|
||||
self.commit(agent)
|
||||
|
||||
|
||||
def get_freezer(backend_name: str) -> Freezer:
|
||||
"""Return the Freezer for the named backend.
|
||||
|
||||
backend_name "" is treated as "docker" for backward compatibility
|
||||
with state dirs written before the backend field was added."""
|
||||
resolved = backend_name or "docker"
|
||||
if resolved == "docker":
|
||||
from .docker.freezer import DockerFreezer
|
||||
return DockerFreezer()
|
||||
if resolved == "macos-container":
|
||||
from .macos_container.freezer import MacosContainerFreezer
|
||||
return MacosContainerFreezer()
|
||||
if resolved == "smolmachines":
|
||||
from .smolmachines.freezer import SmolmachinesFreezer
|
||||
return SmolmachinesFreezer()
|
||||
die(
|
||||
f"commit is only supported for docker, macos-container, and "
|
||||
f"smolmachines; backend {backend_name!r} has no freezer"
|
||||
)
|
||||
raise AssertionError("unreachable")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""macOS Apple Container backend.
|
||||
|
||||
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
|
||||
the Apple `container` CLI integration; launch remains gated until the
|
||||
sidecar network enforcement shape is implemented.
|
||||
"""
|
||||
|
||||
from .backend import MacosContainerBottleBackend
|
||||
|
||||
__all__ = ["MacosContainerBottleBackend"]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""MacosContainerBottleBackend — Apple Container implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
from . import resolve_plan as _resolve_plan
|
||||
from . import util as _container
|
||||
from .bottle import MacosContainerBottle
|
||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
|
||||
class MacosContainerBottleBackend(
|
||||
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
|
||||
):
|
||||
"""Apple Container backend. Selected by
|
||||
`BOT_BOTTLE_BACKEND=macos-container` or
|
||||
`--backend=macos-container`."""
|
||||
|
||||
name = "macos-container"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return _container.is_available()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_resolve_plan.preflight()
|
||||
|
||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return _resolve_plan.build_guest_env(resolved_env)
|
||||
|
||||
def _resolve_plan(
|
||||
self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
stage_dir: Path,
|
||||
) -> MacosContainerBottlePlan:
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def launch(
|
||||
self, plan: MacosContainerBottlePlan
|
||||
) -> Generator[MacosContainerBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
||||
return _cleanup.prepare_cleanup()
|
||||
|
||||
def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None:
|
||||
_cleanup.cleanup(plan)
|
||||
|
||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||
return _enumerate.enumerate_active()
|
||||
|
||||
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
|
||||
return plan.agent_supervise_url
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Bottle handle for Apple's `container` CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Callable, cast
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from ..terminal import exec_shell_script
|
||||
from . import pty_forward as _pty_forward
|
||||
|
||||
|
||||
_PTY_FORWARD_SCRIPT = _pty_forward.__file__
|
||||
_TERMINAL_ENV_NAMES = (
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
"KITTY_WINDOW_ID",
|
||||
"KITTY_PID",
|
||||
"WEZTERM_PANE",
|
||||
"WEZTERM_UNIX_SOCKET",
|
||||
"GHOSTTY_BIN_DIR",
|
||||
"GHOSTTY_RESOURCES_DIR",
|
||||
"ITERM_SESSION_ID",
|
||||
"VTE_VERSION",
|
||||
"KONSOLE_VERSION",
|
||||
"ALACRITTY_WINDOW_ID",
|
||||
)
|
||||
|
||||
|
||||
def _terminal_env_names() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
name for name in _TERMINAL_ENV_NAMES
|
||||
if name == "TERM" or os.environ.get(name)
|
||||
)
|
||||
|
||||
|
||||
class MacosContainerBottle(Bottle):
|
||||
def __init__(
|
||||
self,
|
||||
container: str,
|
||||
teardown: Callable[[], None],
|
||||
prompt_path_in_container: str | None,
|
||||
*,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: PromptMode = "append_file",
|
||||
agent_provider_template: str = "claude",
|
||||
terminal_title: str = "",
|
||||
terminal_color: str = "",
|
||||
agent_workdir: str = "/home/node",
|
||||
):
|
||||
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.terminal_title = terminal_title
|
||||
self.terminal_color = terminal_color
|
||||
self.agent_provider_template = agent_provider_template
|
||||
self.agent_workdir = agent_workdir
|
||||
self._closed = False
|
||||
|
||||
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
||||
full_argv = list(argv)
|
||||
full_argv.extend(
|
||||
prompt_args(
|
||||
cast(PromptMode, self._agent_prompt_mode),
|
||||
self.prompt_path,
|
||||
argv=full_argv,
|
||||
)
|
||||
)
|
||||
container_exec = ["container", "exec"]
|
||||
if tty:
|
||||
container_exec.extend(["--interactive", "--tty"])
|
||||
# Forward terminal capability hints so TUIs can enable modified-key
|
||||
# protocols. Use bare env names: values stay in the child env, not
|
||||
# on argv, and pty_forward supplies a TERM fallback when needed.
|
||||
for name in _terminal_env_names():
|
||||
container_exec.extend(["--env", name])
|
||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
||||
container_exec.extend(["--workdir", self.agent_workdir])
|
||||
container_exec.extend([self.name, self.agent_command, *full_argv])
|
||||
if tty:
|
||||
# Wrap with the raw-mode forwarder: container exec does not put
|
||||
# the host terminal into raw mode itself, so the line discipline
|
||||
# buffers modifier-key sequences until CR. The wrapper sets raw
|
||||
# mode before exec and restores it on exit.
|
||||
return [sys.executable, _PTY_FORWARD_SCRIPT, "--", *container_exec]
|
||||
return container_exec
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
agent_argv = self.agent_argv(argv, tty=tty)
|
||||
script = (
|
||||
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
|
||||
if tty else None
|
||||
)
|
||||
if script is None:
|
||||
return subprocess.run(agent_argv, check=False).returncode
|
||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
result = subprocess.run(
|
||||
["container", "exec", "--user", user, "--interactive",
|
||||
self.name, "sh", "-s"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return ExecResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
|
||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
||||
subprocess.run(
|
||||
["container", "cp", host_path, f"{self.name}:{container_path}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._teardown()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Cleanup plan for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...log import info
|
||||
from .. import BottleCleanupPlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
||||
containers: tuple[str, ...] = ()
|
||||
networks: tuple[str, ...] = ()
|
||||
|
||||
def print(self) -> None:
|
||||
if not self.containers and not self.networks:
|
||||
info("macos-container cleanup: nothing to remove")
|
||||
return
|
||||
for name in self.containers:
|
||||
info(f"macos-container container: {name}")
|
||||
for name in self.networks:
|
||||
info(f"macos-container network: {name}")
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not self.containers and not self.networks
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Plan type for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import PromptMode
|
||||
from .. import BottlePlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MacosContainerBottlePlan(BottlePlan):
|
||||
slug: str
|
||||
forwarded_env: dict[str, str] = field(repr=False)
|
||||
agent_proxy_url: str = ""
|
||||
agent_git_gate_url: str = ""
|
||||
agent_supervise_url: str = ""
|
||||
|
||||
@property
|
||||
def container_name(self) -> str:
|
||||
return self.agent_provision.instance_name
|
||||
|
||||
@property
|
||||
def image(self) -> str:
|
||||
return self.agent_provision.image
|
||||
|
||||
@property
|
||||
def dockerfile_path(self) -> str:
|
||||
return self.agent_provision.dockerfile
|
||||
|
||||
@property
|
||||
def prompt_file(self) -> Path:
|
||||
return self.agent_provision.prompt_file
|
||||
|
||||
@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 git_gate_insteadof_host(self) -> str:
|
||||
if self.agent_git_gate_url.startswith("http://"):
|
||||
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
|
||||
return super().git_gate_insteadof_host
|
||||
|
||||
@property
|
||||
def git_gate_insteadof_scheme(self) -> str:
|
||||
if self.agent_git_gate_url.startswith("http://"):
|
||||
return "http"
|
||||
return super().git_gate_insteadof_scheme
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Cleanup for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ...log import info, warn
|
||||
from . import util as container_mod
|
||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||
|
||||
_PREFIX = "bot-bottle-"
|
||||
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
|
||||
|
||||
|
||||
def _list_prefixed_containers() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--all", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(f"container list failed: {result.stderr.strip()}")
|
||||
return []
|
||||
return sorted(
|
||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||
if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _list_prefixed_networks() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["container", "network", "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return sorted(
|
||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||
if name.startswith(_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
||||
container_mod.require_container()
|
||||
return MacosContainerBottleCleanupPlan(
|
||||
containers=tuple(_list_prefixed_containers()),
|
||||
networks=tuple(_list_prefixed_networks()),
|
||||
)
|
||||
|
||||
|
||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
||||
for name in plan.containers:
|
||||
info(f"container delete --force {name}")
|
||||
subprocess.run(
|
||||
["container", "delete", "--force", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
for name in plan.networks:
|
||||
info(f"container network delete {name}")
|
||||
subprocess.run(
|
||||
["container", "network", "delete", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Host-side egress apply for the macos-container backend.
|
||||
|
||||
Uses `container kill --signal HUP` (Apple Container framework) instead
|
||||
of `docker kill` to signal the sidecar bundle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ...log import warn
|
||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||
from .launch import sidecar_container_name
|
||||
|
||||
|
||||
class MacOSContainerEgressApplicator(EgressApplicator):
|
||||
def _signal_bundle_reload(self, slug: str) -> None:
|
||||
container = sidecar_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["container", "kill", "--signal", "HUP", container],
|
||||
capture_output=True, text=True, check=False, env=os.environ,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
warn(
|
||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
||||
f"{last_error or 'container kill failed'}"
|
||||
)
|
||||
raise EgressApplyError(
|
||||
f"could not reload egress bundle {container}: "
|
||||
f"{last_error or 'container kill failed'}"
|
||||
)
|
||||
|
||||
|
||||
applicator = MacOSContainerEgressApplicator()
|
||||
|
||||
|
||||
__all__ = ["MacOSContainerEgressApplicator", "EgressApplyError", "applicator"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Active-agent enumeration for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ...bottle_state import read_metadata
|
||||
from .. import ActiveAgent
|
||||
|
||||
_PREFIX = "bot-bottle-"
|
||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
out: list[ActiveAgent] = []
|
||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
||||
if not name.startswith(_PREFIX):
|
||||
continue
|
||||
if name.startswith(_SIDECAR_PREFIX):
|
||||
continue
|
||||
slug = name[len(_PREFIX):]
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveAgent(
|
||||
backend_name="macos-container",
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=(),
|
||||
label=metadata.label if metadata else "",
|
||||
color=metadata.color if metadata else "",
|
||||
))
|
||||
return out
|
||||
@@ -0,0 +1,31 @@
|
||||
"""MacosContainerFreezer — snapshot a macOS container bottle.
|
||||
|
||||
Apple Container removes containers when they stop, making stop-then-export
|
||||
impossible. Instead, commit_container execs into the running container and
|
||||
streams the root filesystem via tar. The bottle continues running after commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
from .util import commit_container
|
||||
from ...log import info
|
||||
|
||||
|
||||
class MacosContainerFreezer(Freezer):
|
||||
"""Freezes a macOS-container bottle via exec-tar + image rebuild."""
|
||||
|
||||
backend_name = "macos-container"
|
||||
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
container = f"bot-bottle-{agent.slug}"
|
||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
commit_container(container, image_tag)
|
||||
return image_tag
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(
|
||||
f"to export for migration: "
|
||||
f"container image save {image_ref} -o {slug}.tar"
|
||||
)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Launch flow for the macOS Apple Container backend.
|
||||
|
||||
This backend keeps the explicit proxy-env enforcement model for v1:
|
||||
the agent container is attached only to a host-only Apple Container
|
||||
network, while the sidecar bundle is attached to a NAT network first
|
||||
and the host-only network second. The sidecar's host-only IP is
|
||||
discovered from `container inspect` and stamped into the agent's
|
||||
HTTP_PROXY / HTTPS_PROXY env vars.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from ...egress import (
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_resolve_token_values,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||
from ...log import die, info, warn
|
||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
||||
from ...util import expand_tilde
|
||||
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
||||
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.sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
)
|
||||
from ..docker.egress import egress_tls_init
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from . import util as container_mod
|
||||
from .bottle import MacosContainerBottle
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
||||
_GIT_HTTP_PORT = 9420
|
||||
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
||||
|
||||
|
||||
def internal_network_name(slug: str) -> str:
|
||||
return f"bot-bottle-net-{slug}"
|
||||
|
||||
|
||||
def egress_network_name(slug: str) -> str:
|
||||
return f"bot-bottle-egress-{slug}"
|
||||
|
||||
|
||||
def sidecar_container_name(slug: str) -> str:
|
||||
return f"bot-bottle-sidecars-{slug}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launch(
|
||||
plan: MacosContainerBottlePlan,
|
||||
*,
|
||||
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
||||
) -> Generator[MacosContainerBottle, None, None]:
|
||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
||||
stack = ExitStack()
|
||||
bottle_for_revoke = plan.manifest.bottle
|
||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||
|
||||
def teardown() -> None:
|
||||
teardown_exc: BaseException | None = None
|
||||
try:
|
||||
stack.close()
|
||||
except BaseException as exc: # noqa: W0718 - teardown must continue
|
||||
teardown_exc = exc
|
||||
warn(f"macos-container teardown failed: {exc!r}")
|
||||
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
|
||||
if teardown_exc is not None:
|
||||
raise teardown_exc
|
||||
|
||||
try:
|
||||
plan = _mint_certs(plan)
|
||||
plan = _build_images(plan)
|
||||
|
||||
internal_network = internal_network_name(plan.slug)
|
||||
egress_network = egress_network_name(plan.slug)
|
||||
_create_networks(internal_network, egress_network, stack)
|
||||
|
||||
sidecar_name = sidecar_container_name(plan.slug)
|
||||
container_mod.force_remove_container(sidecar_name)
|
||||
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
|
||||
stack.callback(container_mod.force_remove_container, sidecar_name)
|
||||
_stage_git_gate(plan, sidecar_name)
|
||||
|
||||
sidecar_ip = container_mod.container_ipv4_on_network(
|
||||
sidecar_name, internal_network,
|
||||
)
|
||||
plan = _stamp_agent_urls(plan, sidecar_ip)
|
||||
|
||||
container_mod.force_remove_container(plan.container_name)
|
||||
_start_agent(plan, internal_network, sidecar_ip)
|
||||
stack.callback(container_mod.force_remove_container, plan.container_name)
|
||||
|
||||
bottle = MacosContainerBottle(
|
||||
plan.container_name,
|
||||
teardown,
|
||||
None,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
agent_provider_template=plan.agent_provider_template,
|
||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
)
|
||||
bottle.prompt_path = provision(plan, bottle)
|
||||
|
||||
yield bottle
|
||||
finally:
|
||||
teardown()
|
||||
|
||||
|
||||
def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
||||
egress_state_dir(plan.slug),
|
||||
)
|
||||
egress_plan = dataclasses.replace(
|
||||
plan.egress_plan,
|
||||
mitmproxy_ca_host_path=egress_ca_host,
|
||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||
)
|
||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
||||
|
||||
|
||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
container_mod.build_image(
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
_REPO_DIR,
|
||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||
)
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and container_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(
|
||||
plan.agent_provision,
|
||||
image=committed,
|
||||
),
|
||||
)
|
||||
container_mod.build_image(
|
||||
plan.image,
|
||||
_REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def _create_networks(
|
||||
internal_network: str,
|
||||
egress_network: str,
|
||||
stack: ExitStack,
|
||||
) -> None:
|
||||
container_mod.create_network(internal_network, internal=True)
|
||||
stack.callback(container_mod.remove_network, internal_network)
|
||||
container_mod.create_network(egress_network)
|
||||
stack.callback(container_mod.remove_network, egress_network)
|
||||
|
||||
|
||||
def _start_sidecar_bundle(
|
||||
plan: MacosContainerBottlePlan,
|
||||
sidecar_name: str,
|
||||
internal_network: str,
|
||||
egress_network: str,
|
||||
) -> None:
|
||||
argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network)
|
||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.egress_plan.token_env_map, effective_env,
|
||||
)
|
||||
env = {**os.environ, **token_values}
|
||||
info(f"container run sidecar bundle {sidecar_name}")
|
||||
result = subprocess.run(
|
||||
argv, capture_output=True, text=True, env=env, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container run for sidecar bundle {sidecar_name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _start_agent(
|
||||
plan: MacosContainerBottlePlan,
|
||||
internal_network: str,
|
||||
sidecar_ip: str,
|
||||
) -> None:
|
||||
argv = _agent_run_argv(plan, internal_network, sidecar_ip)
|
||||
env = {
|
||||
**os.environ,
|
||||
**plan.forwarded_env,
|
||||
}
|
||||
info(f"container run agent {plan.container_name}")
|
||||
result = subprocess.run(
|
||||
argv, capture_output=True, text=True, env=env, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container run for agent {plan.container_name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _stamp_agent_urls(
|
||||
plan: MacosContainerBottlePlan,
|
||||
sidecar_ip: str,
|
||||
) -> MacosContainerBottlePlan:
|
||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
||||
supervise_url = ""
|
||||
if plan.supervise_plan is not None:
|
||||
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
|
||||
git_gate_url = ""
|
||||
if plan.git_gate_plan.upstreams:
|
||||
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
agent_proxy_url=proxy_url,
|
||||
agent_git_gate_url=git_gate_url,
|
||||
agent_supervise_url=supervise_url,
|
||||
)
|
||||
|
||||
|
||||
def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
|
||||
gp = plan.git_gate_plan
|
||||
if not gp.upstreams:
|
||||
return
|
||||
|
||||
container_mod.exec_container(
|
||||
sidecar_name,
|
||||
[
|
||||
"mkdir",
|
||||
"-p",
|
||||
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||
"/git",
|
||||
str(Path(_GIT_GATE_READY_FILE).parent),
|
||||
],
|
||||
)
|
||||
|
||||
for host_path, container_path in _git_gate_files(plan):
|
||||
container_mod.copy_into_container(
|
||||
sidecar_name, host_path, container_path,
|
||||
)
|
||||
|
||||
container_mod.exec_container(
|
||||
sidecar_name,
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"chmod 755 "
|
||||
f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
|
||||
f"{GIT_GATE_HOOK_IN_CONTAINER} "
|
||||
f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
|
||||
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
|
||||
f"touch {_GIT_GATE_READY_FILE}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _git_gate_files(
|
||||
plan: MacosContainerBottlePlan,
|
||||
) -> tuple[tuple[str, str], ...]:
|
||||
gp = plan.git_gate_plan
|
||||
files: list[tuple[str, str]] = [
|
||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
|
||||
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
||||
]
|
||||
for upstream in gp.upstreams:
|
||||
files.append((
|
||||
expand_tilde(upstream.identity_file),
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key",
|
||||
))
|
||||
if upstream.known_hosts_file:
|
||||
files.append((
|
||||
str(upstream.known_hosts_file),
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts",
|
||||
))
|
||||
return tuple(files)
|
||||
|
||||
|
||||
def _sidecar_run_argv(
|
||||
plan: MacosContainerBottlePlan,
|
||||
sidecar_name: str,
|
||||
internal_network: str,
|
||||
egress_network: str,
|
||||
) -> list[str]:
|
||||
argv = [
|
||||
"container", "run",
|
||||
"--name", sidecar_name,
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--network", egress_network,
|
||||
"--network", internal_network,
|
||||
"--dns", _sidecar_dns(),
|
||||
"--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}",
|
||||
]
|
||||
for entry in _sidecar_env_entries(plan):
|
||||
argv += ["--env", entry]
|
||||
for host_path, container_path, read_only in _sidecar_mounts(plan):
|
||||
argv += ["--mount", _mount_spec(host_path, container_path, read_only)]
|
||||
argv.append(SIDECAR_BUNDLE_IMAGE)
|
||||
return argv
|
||||
|
||||
|
||||
def _agent_run_argv(
|
||||
plan: MacosContainerBottlePlan,
|
||||
internal_network: str,
|
||||
sidecar_ip: str,
|
||||
) -> list[str]:
|
||||
argv = [
|
||||
"container", "run",
|
||||
"--name", plan.container_name,
|
||||
"--detach",
|
||||
"--network", internal_network,
|
||||
]
|
||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
||||
argv += ["--env", entry]
|
||||
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
|
||||
return argv
|
||||
|
||||
|
||||
def _sidecar_dns() -> str:
|
||||
return container_mod.dns_server()
|
||||
|
||||
|
||||
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||
daemons = ["egress"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
daemons += ["git-gate", "git-http"]
|
||||
if plan.supervise_plan is not None:
|
||||
daemons.append("supervise")
|
||||
return tuple(daemons)
|
||||
|
||||
|
||||
def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
|
||||
if plan.git_gate_plan.upstreams:
|
||||
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
|
||||
if plan.supervise_plan is not None:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
return tuple(env)
|
||||
|
||||
|
||||
def _sidecar_mounts(
|
||||
plan: MacosContainerBottlePlan,
|
||||
) -> tuple[tuple[str, str, bool], ...]:
|
||||
mounts: list[tuple[str, str, bool]] = []
|
||||
|
||||
ep = plan.egress_plan
|
||||
mounts.append((
|
||||
str(ep.mitmproxy_ca_host_path.parent),
|
||||
str(Path(EGRESS_CA_IN_CONTAINER).parent),
|
||||
False,
|
||||
))
|
||||
if ep.routes:
|
||||
mounts.append((
|
||||
str(ep.routes_path.parent),
|
||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
||||
True,
|
||||
))
|
||||
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||
|
||||
return tuple(mounts)
|
||||
|
||||
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
|
||||
spec = f"type=bind,source={host_path},target={container_path}"
|
||||
if read_only:
|
||||
spec += ",readonly"
|
||||
return spec
|
||||
|
||||
|
||||
def _agent_env_entries(
|
||||
plan: MacosContainerBottlePlan,
|
||||
sidecar_ip: str,
|
||||
) -> tuple[str, ...]:
|
||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
||||
no_proxy = _agent_no_proxy(plan, sidecar_ip)
|
||||
env = [
|
||||
f"HTTPS_PROXY={proxy_url}",
|
||||
f"HTTP_PROXY={proxy_url}",
|
||||
f"https_proxy={proxy_url}",
|
||||
f"http_proxy={proxy_url}",
|
||||
f"NO_PROXY={no_proxy}",
|
||||
f"no_proxy={no_proxy}",
|
||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
||||
]
|
||||
if plan.agent_git_gate_url:
|
||||
env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}")
|
||||
if plan.agent_supervise_url:
|
||||
env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}")
|
||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
||||
env.append(f"{name}={value}")
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
return tuple(env)
|
||||
|
||||
|
||||
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
||||
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
||||
return ",".join(hosts)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Host-side raw-mode wrapper for `container exec --interactive --tty`.
|
||||
|
||||
Apple's `container exec --interactive --tty` does not set the host terminal to
|
||||
raw mode before starting its I/O relay. Without raw mode the kernel line
|
||||
discipline buffers modifier-key escape sequences (e.g. Shift+Enter in
|
||||
modifyOtherKeys mode produces \\x1b[13;2~) until a carriage-return arrives, so
|
||||
they never reach Claude Code inside the container.
|
||||
|
||||
This module sets the host terminal to raw mode, spawns the inner argv (the
|
||||
container exec command), and restores the original terminal attributes on
|
||||
exit. When stdin is not a TTY (piped invocations, CI) it falls through to a
|
||||
bare subprocess.run so callers do not need to special-case non-interactive
|
||||
contexts.
|
||||
|
||||
Usage (the `--` separator is the API contract — everything after it is the
|
||||
inner command):
|
||||
|
||||
python pty_forward.py -- container exec --interactive --tty <name> <cmd>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
|
||||
def _inner_env() -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env.setdefault("TERM", "xterm-256color")
|
||||
return env
|
||||
|
||||
|
||||
def _run_inner(inner: list[str]) -> int:
|
||||
return subprocess.run(inner, check=False, env=_inner_env()).returncode
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Entry point. ``argv`` shape: ``-- <inner-argv...>``."""
|
||||
if len(argv) < 2 or argv[0] != "--":
|
||||
sys.stderr.write(
|
||||
"usage: python pty_forward.py -- <container-exec-argv...>\n"
|
||||
)
|
||||
return 2
|
||||
inner = argv[1:]
|
||||
|
||||
try:
|
||||
fd = sys.stdin.fileno()
|
||||
except OSError:
|
||||
return _run_inner(inner)
|
||||
|
||||
if not os.isatty(fd):
|
||||
return _run_inner(inner)
|
||||
|
||||
try:
|
||||
old = termios.tcgetattr(fd)
|
||||
except termios.error:
|
||||
return _run_inner(inner)
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
return _run_inner(inner)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Prepare step for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
from .. import BottleSpec
|
||||
from . import util as container_mod
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
|
||||
def preflight() -> None:
|
||||
container_mod.require_container()
|
||||
|
||||
|
||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return dict(resolved_env.literals)
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
git_gate_plan: GitGatePlan,
|
||||
stage_dir: Path,
|
||||
) -> MacosContainerBottlePlan:
|
||||
return MacosContainerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
forwarded_env=dict(resolved_env.forwarded),
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
agent_provision=agent_provision_plan,
|
||||
)
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Host-side primitives for Apple's `container` CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ipaddress
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
_CONTAINER = "container"
|
||||
_DEFAULT_DNS = "1.1.1.1"
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return is_macos() and shutil.which(_CONTAINER) is not None
|
||||
|
||||
|
||||
def require_container() -> None:
|
||||
"""Fail with an install pointer if Apple Container is unavailable."""
|
||||
if not is_macos():
|
||||
info("BOT_BOTTLE_BACKEND=macos-container requires macOS.")
|
||||
die("macos-container backend is only supported on macOS")
|
||||
if shutil.which(_CONTAINER) is None:
|
||||
info("Apple Container is required but was not found on PATH.")
|
||||
info("Install: https://github.com/apple/container/releases")
|
||||
die("container not found")
|
||||
_require_container_service()
|
||||
|
||||
|
||||
def _require_container_service() -> None:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "system", "status"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
info("Apple Container system service is not running.")
|
||||
info("Start it with: container system start")
|
||||
die("container system service not running")
|
||||
|
||||
|
||||
def dns_server() -> str:
|
||||
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
||||
if override:
|
||||
return override
|
||||
return _host_ipv4_dns() or _DEFAULT_DNS
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
||||
info(
|
||||
f"building image {ref} from {context} with Apple Container "
|
||||
"(layer cache keeps repeat builds fast)"
|
||||
)
|
||||
_ensure_builder_dns()
|
||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||
if dockerfile:
|
||||
# `container build` resolves -f relative to the current working
|
||||
# directory, not the build context. Anchor a relative Dockerfile to
|
||||
# the context so builds work from any cwd.
|
||||
if not os.path.isabs(dockerfile):
|
||||
dockerfile = os.path.join(context, dockerfile)
|
||||
args.extend(["-f", dockerfile])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def commit_container(container_name: str, image_tag: str) -> None:
|
||||
"""Snapshot a running Apple Container as a local image.
|
||||
|
||||
`container export` requires a stopped container, but Apple Container
|
||||
removes containers when they stop, making stop-then-export impossible.
|
||||
Instead, exec into the running container as root and stream the root
|
||||
filesystem out via tar, then build a new image from that archive.
|
||||
The bottle continues running after commit.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
|
||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
||||
dockerfile = os.path.join(tmp, "Dockerfile")
|
||||
with open(rootfs_tar, "wb") as tar_out:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_CONTAINER, "exec",
|
||||
"--user", "root",
|
||||
container_name,
|
||||
"tar", "--create",
|
||||
"--exclude=./proc",
|
||||
"--exclude=./sys",
|
||||
"--exclude=./dev",
|
||||
"--exclude=./run",
|
||||
"--file=-",
|
||||
"--directory=/",
|
||||
".",
|
||||
],
|
||||
stdout=tar_out,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container exec tar {container_name!r} failed: "
|
||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
|
||||
)
|
||||
with open(dockerfile, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"FROM scratch\n"
|
||||
"ADD rootfs.tar /\n"
|
||||
"USER node\n"
|
||||
"WORKDIR /home/node\n"
|
||||
)
|
||||
build_image(image_tag, tmp, dockerfile=dockerfile)
|
||||
info(f"committed {container_name!r} → {image_tag!r}")
|
||||
|
||||
|
||||
def _ensure_builder_dns() -> None:
|
||||
dns = dns_server()
|
||||
status = _builder_status()
|
||||
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
||||
if _builder_running(status) and _builder_resolves_build_hosts():
|
||||
if override and not _builder_has_dns(status, dns):
|
||||
_restart_builder_with_dns(dns)
|
||||
return
|
||||
_restart_builder_with_dns(dns)
|
||||
|
||||
|
||||
def _restart_builder_with_dns(dns: str) -> None:
|
||||
subprocess.run(
|
||||
[_CONTAINER, "builder", "stop"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run(
|
||||
[_CONTAINER, "builder", "start", "--dns", dns],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _host_ipv4_dns() -> str:
|
||||
if not is_macos():
|
||||
return ""
|
||||
result = subprocess.run(
|
||||
["scutil", "--dns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
blocks: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("resolver #") and current:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
current.append(line)
|
||||
if current:
|
||||
blocks.append(current)
|
||||
for direct_only in (True, False):
|
||||
for block in blocks:
|
||||
text = "\n".join(block)
|
||||
if direct_only and "Directly Reachable Address" not in text:
|
||||
continue
|
||||
for line in block:
|
||||
if "nameserver[" not in line or ":" not in line:
|
||||
continue
|
||||
candidate = line.split(":", 1)[1].strip()
|
||||
if _usable_ipv4(candidate):
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _usable_ipv4(value: str) -> bool:
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
address.version == 4
|
||||
and not address.is_loopback
|
||||
and not address.is_link_local
|
||||
and not address.is_multicast
|
||||
and not address.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _builder_status() -> list[dict[str, object]]:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "builder", "status", "--format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if isinstance(data, list):
|
||||
return [entry for entry in data if isinstance(entry, dict)]
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
return []
|
||||
|
||||
|
||||
def _builder_running(status: list[dict[str, object]]) -> bool:
|
||||
for entry in status:
|
||||
entry_status = entry.get("status")
|
||||
if isinstance(entry_status, dict) and entry_status.get("state") == "running":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _builder_dns_nameservers(status: list[dict[str, object]]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for entry in status:
|
||||
config = entry.get("configuration")
|
||||
config_dns = config.get("dns") if isinstance(config, dict) else None
|
||||
nameservers = (
|
||||
config_dns.get("nameservers")
|
||||
if isinstance(config_dns, dict)
|
||||
else None
|
||||
)
|
||||
if not isinstance(nameservers, list):
|
||||
continue
|
||||
out.extend(name for name in nameservers if isinstance(name, str))
|
||||
return out
|
||||
|
||||
|
||||
def _builder_has_dns(status: list[dict[str, object]], dns: str) -> bool:
|
||||
return dns in _builder_dns_nameservers(status)
|
||||
|
||||
|
||||
def _builder_resolves_build_hosts() -> bool:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "exec", "buildkit", "getent", "hosts", "deb.debian.org"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def image_exists(ref: str) -> bool:
|
||||
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
|
||||
|
||||
|
||||
def container_exists(name: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "list", "--all", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||
|
||||
|
||||
def container_is_running(name: str) -> bool:
|
||||
"""Return True if the named container is currently running.
|
||||
|
||||
`container list` without `--all` lists only running containers."""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||
|
||||
|
||||
def stop_container(name: str) -> None:
|
||||
"""Stop the named container without deleting it."""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "stop", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container stop {name!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def force_remove_container(name: str) -> None:
|
||||
if container_exists(name):
|
||||
subprocess.run(
|
||||
[_CONTAINER, "delete", "--force", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def copy_into_container(name: str, host_path: str, container_path: str) -> None:
|
||||
cmd = [_CONTAINER, "cp", host_path, f"{name}:{container_path}"]
|
||||
result = _run_container_op(cmd)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container cp into {name}:{container_path} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def exec_container(name: str, argv: list[str]) -> None:
|
||||
result = _run_container_op([_CONTAINER, "exec", name, *argv])
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container exec in {name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
for _ in range(19):
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def create_network(name: str, *, internal: bool = False) -> None:
|
||||
args = [
|
||||
_CONTAINER, "network", "create",
|
||||
"--label", "bot-bottle.backend=macos-container",
|
||||
]
|
||||
if internal:
|
||||
args.append("--internal")
|
||||
args.append(name)
|
||||
result = subprocess.run(
|
||||
args, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
if "already exists" in (result.stderr or "").lower():
|
||||
return
|
||||
die(
|
||||
f"container network create {name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def remove_network(name: str) -> None:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "network", "delete", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
|
||||
def inspect_container(name: str) -> dict[str, object]:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "inspect", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container inspect {name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
die(f"container inspect {name} returned malformed JSON: {exc}")
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
return data[0]
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
die(f"container inspect {name} returned an unexpected shape")
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def container_ipv4_on_network(name: str, network: str) -> str:
|
||||
data = inspect_container(name)
|
||||
status = data.get("status")
|
||||
networks = status.get("networks") if isinstance(status, dict) else None
|
||||
if not isinstance(networks, list):
|
||||
die(f"container inspect {name} did not include status.networks")
|
||||
for entry in networks:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("network") != network:
|
||||
continue
|
||||
raw = entry.get("ipv4Address")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
die(f"container {name} has no IPv4 address on {network}")
|
||||
return raw.split("/", 1)[0]
|
||||
die(f"container {name} is not attached to network {network}")
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
"""Return the image digest/ID from `container image inspect`.
|
||||
|
||||
The command returns JSON on current Apple Container releases. Keep
|
||||
parsing narrow and fatal so callers do not cache on an empty key.
|
||||
"""
|
||||
import json
|
||||
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "image", "inspect", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container image inspect for {ref!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
|
||||
if isinstance(data, list) and data:
|
||||
data = data[0]
|
||||
if isinstance(data, dict):
|
||||
value = data.get("id") or data.get("digest") or data.get("ID")
|
||||
if value:
|
||||
return str(value)
|
||||
die(f"container image inspect for {ref!r} did not include an image id")
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def save(ref: str, output: str) -> None:
|
||||
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
|
||||
|
||||
|
||||
def _silent_run(cmd: Iterable[str]) -> int:
|
||||
return subprocess.run(
|
||||
list(cmd),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Shared helpers used by both backends' resolve_plan steps.
|
||||
|
||||
Each helper owns one well-defined step of the per-bottle plan
|
||||
resolution so docker and smolmachines don't repeat the same logic.
|
||||
Backend-specific steps (container names, env-file, per-bottle
|
||||
Dockerfile overrides, subnet allocation) stay in the backend's own
|
||||
resolve_plan.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan
|
||||
from ..bottle_state import (
|
||||
BottleMetadata,
|
||||
agent_state_dir,
|
||||
bottle_identity,
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
supervise_state_dir,
|
||||
write_metadata,
|
||||
)
|
||||
from ..egress import Egress, EgressPlan
|
||||
from ..git_gate import GitGate, GitGatePlan
|
||||
from ..manifest import Manifest, ManifestBottle
|
||||
from ..supervise import Supervise, SupervisePlan
|
||||
from . import BottleSpec
|
||||
|
||||
|
||||
def mint_slug(spec: BottleSpec) -> str:
|
||||
"""Return the bottle identity: the recorded identity for a resume,
|
||||
or a freshly minted one for a new start.
|
||||
|
||||
When a label is provided it becomes the full slug (no random suffix),
|
||||
so two launches with the same label collide by design. When no label
|
||||
is given the identity is minted with a random suffix to avoid
|
||||
collisions between anonymous launches of the same agent."""
|
||||
if spec.identity:
|
||||
return spec.identity
|
||||
if spec.label:
|
||||
from .docker import util as docker_mod
|
||||
return docker_mod.slugify(spec.label)
|
||||
return bottle_identity(spec.agent_name)
|
||||
|
||||
|
||||
def write_launch_metadata(
|
||||
slug: str, spec: BottleSpec, *, compose_project: str, backend: str,
|
||||
) -> None:
|
||||
"""Persist launch metadata so `cli.py resume <identity>` can
|
||||
reconstruct the spec. Idempotent — re-writes on resume with a
|
||||
refreshed started_at."""
|
||||
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=compose_project,
|
||||
backend=backend,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
))
|
||||
|
||||
|
||||
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
||||
"""Create the agent state subdir, write the prompt file.
|
||||
Returns (agent_dir, prompt_file)."""
|
||||
agent = manifest.agent
|
||||
agent_dir = agent_state_dir(slug)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = agent_dir / "prompt.txt"
|
||||
prompt_file.write_text(agent.prompt or "")
|
||||
prompt_file.chmod(0o600)
|
||||
return agent_dir, prompt_file
|
||||
|
||||
|
||||
def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
|
||||
git_gate_dir = git_gate_state_dir(slug)
|
||||
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
||||
return GitGate().prepare(bottle, slug, git_gate_dir)
|
||||
|
||||
|
||||
def prepare_egress(
|
||||
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
|
||||
) -> EgressPlan:
|
||||
egress_dir = egress_state_dir(slug)
|
||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
||||
|
||||
|
||||
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
||||
"""Prepare the supervise sidecar state dir. Returns None when
|
||||
bottle.supervise is falsy."""
|
||||
if not bottle.supervise:
|
||||
return None
|
||||
supervise_dir = supervise_state_dir(slug)
|
||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||
return Supervise().prepare(slug, supervise_dir)
|
||||
|
||||
|
||||
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
|
||||
"""Fold provision.env_vars into guest_env (setdefault semantics)
|
||||
and return a new plan with the merged guest_env."""
|
||||
merged = dict(provision.guest_env)
|
||||
for key, val in provision.env_vars.items():
|
||||
merged.setdefault(key, val)
|
||||
return replace(provision, guest_env=merged)
|
||||
|
||||
|
||||
def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||
"""Resolve a manifest-supplied dockerfile path relative to user_cwd."""
|
||||
path = Path(os.path.expanduser(path_value))
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
return str(path)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"merge_provision_env_vars",
|
||||
"mint_slug",
|
||||
"prepare_agent_state_dir",
|
||||
"prepare_egress",
|
||||
"prepare_git_gate",
|
||||
"prepare_supervise",
|
||||
"resolve_manifest_dockerfile",
|
||||
"write_launch_metadata",
|
||||
]
|
||||
@@ -13,18 +13,21 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
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 resolve_plan as _resolve_plan
|
||||
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 workspace as _workspace
|
||||
|
||||
|
||||
class SmolmachinesBottleBackend(
|
||||
@@ -43,10 +46,36 @@ class SmolmachinesBottleBackend(
|
||||
runtime check happens at `prepare`."""
|
||||
return _smolvm.is_available()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_resolve_plan.preflight()
|
||||
|
||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return _resolve_plan.build_guest_env(resolved_env)
|
||||
|
||||
def _resolve_plan(
|
||||
self, spec: BottleSpec, *, stage_dir: Path
|
||||
self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
stage_dir: Path,
|
||||
) -> SmolmachinesBottlePlan:
|
||||
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def launch(
|
||||
@@ -55,21 +84,6 @@ class SmolmachinesBottleBackend(
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
def provision_ca(
|
||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
_ca.provision_ca(plan, bottle)
|
||||
|
||||
def provision_workspace(
|
||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
_workspace.provision_workspace(plan, bottle)
|
||||
|
||||
def provision_git(
|
||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
_git.provision_git(plan, bottle)
|
||||
|
||||
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
||||
"""The smolmachines guest reaches the supervise sidecar via a
|
||||
host-published random port the launch step pinned earlier
|
||||
|
||||
@@ -19,10 +19,13 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import shlex
|
||||
from typing import Mapping, cast
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from ..terminal import exec_shell_script
|
||||
from . import pty_resize as _pty_resize
|
||||
from . import smolvm as _smolvm
|
||||
|
||||
@@ -67,6 +70,10 @@ class SmolmachinesBottle(Bottle):
|
||||
guest_env: Mapping[str, str] | None = None,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: PromptMode = "append_file",
|
||||
agent_provider_template: str = "claude",
|
||||
terminal_title: str = "",
|
||||
terminal_color: str = "",
|
||||
agent_workdir: str = "/home/node",
|
||||
) -> None:
|
||||
self.name = machine_name
|
||||
# In-VM path to the agent's prompt file. None when the
|
||||
@@ -80,9 +87,10 @@ class SmolmachinesBottle(Bottle):
|
||||
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"
|
||||
)
|
||||
self.terminal_title = terminal_title
|
||||
self.terminal_color = terminal_color
|
||||
self.agent_provider_template = agent_provider_template
|
||||
self.agent_workdir = agent_workdir
|
||||
|
||||
def agent_argv(
|
||||
self, argv: list[str], *, tty: bool = True,
|
||||
@@ -90,8 +98,14 @@ class SmolmachinesBottle(Bottle):
|
||||
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]
|
||||
agent_tail = ["env", *_env_assignments_for("node", self._guest_env)]
|
||||
if self.agent_workdir and self.agent_workdir != _HOME_FOR["node"]:
|
||||
agent_tail += [
|
||||
"sh", "-lc",
|
||||
f"cd {shlex.quote(self.agent_workdir)} && exec \"$@\"",
|
||||
"bot-bottle-agent",
|
||||
]
|
||||
agent_tail.append(self.agent_command)
|
||||
provider_prompt_args = prompt_args(
|
||||
cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=argv,
|
||||
)
|
||||
@@ -127,9 +141,21 @@ class SmolmachinesBottle(Bottle):
|
||||
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
|
||||
agent_argv = self.agent_argv(argv, tty=tty)
|
||||
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||
if script is None:
|
||||
return subprocess.run(agent_argv, check=False).returncode
|
||||
# Use sh -c (not -lc) so the script inherits PATH from the calling
|
||||
# process. sh -l sources login-shell init files (e.g. /etc/profile)
|
||||
# which may NOT include smolvm's location when it was installed via
|
||||
# homebrew. The calling process (./cli.py) already has smolvm on PATH
|
||||
# (provision steps succeed), so -c is sufficient.
|
||||
return subprocess.run(["sh", "-c", script], check=False).returncode
|
||||
|
||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
||||
# early-VM provisioning. Retry once after a short settle so
|
||||
# callers (provision_ca, etc.) don't have to handle it themselves.
|
||||
_SIGKILL_EXIT = 128 + 9
|
||||
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
"""Run a POSIX shell script as `user` (default `node`) and
|
||||
@@ -141,14 +167,22 @@ class SmolmachinesBottle(Bottle):
|
||||
|
||||
`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."""
|
||||
bottle env in the child process.
|
||||
|
||||
Retries once on SIGKILL (exit 137) — libkrun occasionally
|
||||
kills short-lived execs during VM bring-up."""
|
||||
r = self._exec_raw(script, user=user)
|
||||
if r.returncode == self._SIGKILL_EXIT:
|
||||
time.sleep(1.0)
|
||||
r = self._exec_raw(script, user=user)
|
||||
return r
|
||||
|
||||
def _exec_raw(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
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,
|
||||
|
||||
@@ -29,27 +29,6 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
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.
|
||||
@@ -57,11 +36,6 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
# `--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
|
||||
@@ -82,6 +56,42 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
agent_git_gate_host: str = ""
|
||||
agent_supervise_url: str = ""
|
||||
|
||||
@property
|
||||
def machine_name(self) -> str:
|
||||
"""smolvm machine name. `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."""
|
||||
return self.agent_provision.instance_name
|
||||
|
||||
@property
|
||||
def agent_image(self) -> 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."""
|
||||
return self.agent_provision.image
|
||||
|
||||
@property
|
||||
def prompt_file(self) -> Path:
|
||||
"""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."""
|
||||
return self.agent_provision.prompt_file
|
||||
|
||||
@property
|
||||
def git_gate_insteadof_host(self) -> str:
|
||||
return self.agent_git_gate_host
|
||||
|
||||
@property
|
||||
def git_gate_insteadof_scheme(self) -> str:
|
||||
return "http"
|
||||
|
||||
@property
|
||||
def agent_command(self) -> str:
|
||||
return self.agent_provision.command
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Egress apply for the smolmachines backend.
|
||||
|
||||
The smolmachines sidecar bundle runs as a host-side Docker container,
|
||||
so egress signalling is identical to the docker backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..docker.egress_apply import ( # noqa: F401
|
||||
DockerEgressApplicator,
|
||||
EgressApplyError,
|
||||
applicator,
|
||||
fetch_current_routes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DockerEgressApplicator",
|
||||
"EgressApplyError",
|
||||
"applicator",
|
||||
"fetch_current_routes",
|
||||
]
|
||||
@@ -23,7 +23,7 @@ import json
|
||||
import subprocess
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..docker.bottle_state import read_metadata
|
||||
from ...bottle_state import read_metadata
|
||||
from . import sidecar_bundle as _bundle
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ def enumerate_active() -> list[ActiveAgent]:
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=services_by_slug.get(slug, ()),
|
||||
label=metadata.label if metadata else "",
|
||||
color=metadata.color if metadata else "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""SmolmachinesFreezer — snapshot a smolmachines bottle.
|
||||
|
||||
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
|
||||
removes VMs when stopped (same issue as Apple Container). Instead, exec
|
||||
into the running VM as root to write a gzip-compressed tar of the root
|
||||
filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
|
||||
build a Docker image from the archive, convert it to a smolmachine artifact
|
||||
via the existing registry pipeline, and record the sidecar path. The VM
|
||||
stays running throughout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
from ..docker import util as docker_mod
|
||||
from .local_registry import crane_push_tarball, ephemeral_registry
|
||||
from .smolvm import machine_cp, machine_exec, pack_create
|
||||
from ...bottle_state import bottle_state_dir
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
# Temp file written inside the VM during commit. Lives in /var/tmp
|
||||
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
|
||||
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
|
||||
|
||||
|
||||
class SmolmachinesFreezer(Freezer):
|
||||
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
|
||||
|
||||
The VM is NOT stopped. We exec into the running VM to write a compressed
|
||||
tar of the root filesystem to /var/tmp, copy it to the host with
|
||||
machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
|
||||
automatically), then run the same image→registry→pack_create pipeline
|
||||
that _ensure_smolmachine uses for fresh builds."""
|
||||
|
||||
backend_name = "smolmachines"
|
||||
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
machine = f"bot-bottle-{agent.slug}"
|
||||
image_ref = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
output_dir = bottle_state_dir(agent.slug)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
binary = output_dir / "committed-smolmachine"
|
||||
sidecar = output_dir / "committed-smolmachine.smolmachine"
|
||||
_snapshot_running_vm(machine, image_ref, binary)
|
||||
return str(sidecar)
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(f"to export for migration: cp {image_ref} {slug}.smolmachine")
|
||||
|
||||
|
||||
def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
|
||||
"""Exec-tar the running VM, build a Docker image, and pack to a smolmachine.
|
||||
|
||||
binary: destination for the launcher (sibling .smolmachine is the artifact
|
||||
that machine_create --from consumes, same convention as pack_create).
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
# Use .tar.gz — Docker ADD decompresses automatically and the
|
||||
# compressed archive fits in the VM's /var/tmp more easily.
|
||||
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
|
||||
_exec_tar_to_file(machine, rootfs_tar_gz)
|
||||
|
||||
dockerfile.write_text(
|
||||
"FROM scratch\n"
|
||||
"ADD rootfs.tar.gz /\n"
|
||||
"USER node\n"
|
||||
"WORKDIR /home/node\n"
|
||||
)
|
||||
docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile))
|
||||
|
||||
image_tarball = binary.parent / "committed.image.tar"
|
||||
docker_mod.save(image_ref, str(image_tarball))
|
||||
try:
|
||||
with ephemeral_registry() as handle:
|
||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
||||
push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}"
|
||||
pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}"
|
||||
crane_push_tarball(handle, str(image_tarball), push_ref)
|
||||
pack_create(pack_ref, binary)
|
||||
finally:
|
||||
image_tarball.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _exec_tar_to_file(machine: str, dest: Path) -> None:
|
||||
"""Snapshot the running VM's root filesystem to dest (.tar.gz).
|
||||
|
||||
Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
|
||||
machine_exec (same mechanism as provisioning), then copies it to the
|
||||
host with machine_cp. This avoids binary-stdout piping through the
|
||||
smolvm exec channel, which does not reliably handle large binary output.
|
||||
|
||||
A connectivity probe (machine_exec true) runs first so a concurrent-exec
|
||||
limitation (smolvm may reject a second exec while -i -t is active) is
|
||||
reported clearly rather than as a silent failure."""
|
||||
# Connectivity probe — if smolvm rejects concurrent exec while an
|
||||
# interactive session is running, fail clearly here.
|
||||
probe = machine_exec(machine, ["true"])
|
||||
if probe.returncode != 0:
|
||||
die(
|
||||
f"smolvm exec is not available for {machine!r} "
|
||||
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
|
||||
f"If an interactive session is active, smolvm may not support concurrent exec."
|
||||
)
|
||||
|
||||
# Create the compressed tar inside the VM.
|
||||
# tar exits 1 when files change during archiving (normal for a live
|
||||
# filesystem); only treat exit > 1 as fatal.
|
||||
tar_result = machine_exec(
|
||||
machine,
|
||||
[
|
||||
"tar", "--create", "--gzip",
|
||||
"--exclude=./proc",
|
||||
"--exclude=./sys",
|
||||
"--exclude=./dev",
|
||||
"--exclude=./run",
|
||||
# /tmp and /var/tmp are ephemeral. Their stale contents
|
||||
# (e.g. /tmp/claude-<uid>) have uid remapped by smolvm's
|
||||
# pack process, causing Claude Code to refuse to use them
|
||||
# on resume. Exclude both; _init_vm recreates them with
|
||||
# mkdir -p + correct ownership on every boot.
|
||||
"--exclude=./tmp",
|
||||
"--exclude=./var/tmp",
|
||||
f"--file={_VM_COMMIT_TAR}",
|
||||
"--directory=/",
|
||||
".",
|
||||
],
|
||||
)
|
||||
if tar_result.returncode > 1:
|
||||
die(
|
||||
f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
|
||||
f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
|
||||
)
|
||||
|
||||
# Copy from VM to host, then clean up.
|
||||
try:
|
||||
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
|
||||
finally:
|
||||
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])
|
||||
@@ -23,7 +23,9 @@ from typing import Callable, Generator
|
||||
|
||||
from ...egress import (
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_resolve_token_values,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
||||
from ...util import expand_tilde
|
||||
@@ -40,8 +42,12 @@ from ..docker.git_gate import (
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
)
|
||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||
from ...log import warn
|
||||
from ..docker.bottle_state import egress_state_dir, git_gate_state_dir
|
||||
from ...log import info, warn
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from . import loopback_alias as _loopback
|
||||
from . import sidecar_bundle as _bundle
|
||||
from . import smolvm as _smolvm
|
||||
@@ -85,14 +91,7 @@ def launch(
|
||||
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,
|
||||
)
|
||||
agent_from_path = _agent_from_path(plan)
|
||||
|
||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||
_init_vm(plan)
|
||||
@@ -103,6 +102,10 @@ def launch(
|
||||
guest_env=plan.guest_env,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
agent_provider_template=plan.agent_provider_template,
|
||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
)
|
||||
bottle.prompt_path = provision(plan, bottle)
|
||||
|
||||
@@ -126,7 +129,7 @@ def _teardown_smolmachines(
|
||||
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||
teardown_exc = exc
|
||||
warn(f"smolmachines teardown failed: {exc!r}")
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
bottle = plan.manifest.bottle
|
||||
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
||||
if teardown_exc is not None:
|
||||
raise teardown_exc
|
||||
@@ -213,16 +216,23 @@ def _discover_urls(
|
||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
||||
|
||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
||||
no_proxy = f"{existing_no_proxy},{loopback_ip}"
|
||||
guest_env = {
|
||||
**plan.guest_env,
|
||||
"HTTPS_PROXY": agent_proxy_url,
|
||||
"HTTP_PROXY": agent_proxy_url,
|
||||
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
|
||||
"https_proxy": agent_proxy_url,
|
||||
"http_proxy": agent_proxy_url,
|
||||
"NO_PROXY": no_proxy,
|
||||
"no_proxy": no_proxy,
|
||||
}
|
||||
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
|
||||
for entry in egress_agent_env_entries(plan.egress_plan):
|
||||
name, value = entry.split("=", 1)
|
||||
guest_env[name] = value
|
||||
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
@@ -271,10 +281,16 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
||||
All folded into one sh -c to avoid back-to-back exec calls
|
||||
immediately after machine_start (libkrun exec-channel race).
|
||||
|
||||
mkdir -p guards: when booting from a committed snapshot, /tmp and
|
||||
/var/tmp are excluded from the archive (they're ephemeral and their
|
||||
stale contents would have wrong uid after smolvm's uid remap). The
|
||||
directories must be created before chown/chmod can set permissions.
|
||||
|
||||
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",
|
||||
"mkdir -p /tmp /var/tmp && "
|
||||
"chown -R node:node /home/node && "
|
||||
"chown root:root /tmp /var/tmp && "
|
||||
"chmod 1777 /tmp /var/tmp",
|
||||
@@ -304,12 +320,8 @@ def _bundle_launch_spec(
|
||||
ep = plan.egress_plan
|
||||
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
|
||||
if ep.routes:
|
||||
volumes.append((str(ep.routes_path), EGRESS_ROUTES_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)
|
||||
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
||||
env.extend(egress_sidecar_env_entries(ep))
|
||||
|
||||
# --- git-gate ---------------------------------------------
|
||||
gp = plan.git_gate_plan
|
||||
@@ -378,6 +390,30 @@ def _resolve_token_env(
|
||||
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
||||
|
||||
|
||||
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||
"""Return the `.smolmachine` artifact used for `machine create --from`.
|
||||
|
||||
Prefer a committed VM artifact when one is recorded and still
|
||||
present. If the file was removed, fall back to the normal image
|
||||
build + pack cache path.
|
||||
"""
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed:
|
||||
committed_path = Path(committed)
|
||||
if committed_path.is_file():
|
||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
||||
return committed_path
|
||||
|
||||
# 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.
|
||||
return _ensure_smolmachine(
|
||||
plan.agent_image,
|
||||
dockerfile=plan.agent_dockerfile_path,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||
"""Build the agent docker image and convert it into a
|
||||
`.smolmachine` artifact, caching the result under
|
||||
|
||||
@@ -1,181 +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,
|
||||
supervise_state_dir,
|
||||
write_metadata,
|
||||
)
|
||||
from ...egress import Egress
|
||||
from ...env import resolve_env
|
||||
from ...git_gate import GitGate
|
||||
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 — git-gate's
|
||||
# git-daemon, supervise's MCP. The agent inside the smolvm guest
|
||||
# dials these on the bundle's pinned IP.
|
||||
_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 = "/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)
|
||||
|
||||
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,
|
||||
guest_home=guest_home,
|
||||
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,
|
||||
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)
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||
declarative provision-plan apply, supervise MCP registration) live on
|
||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||
left in this subpackage handle only the steps that are
|
||||
backend-specific:
|
||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
|
||||
provisioning also moved to the AgentProvider ABC (with Debian/node
|
||||
defaults); user plugins override them for non-standard images.
|
||||
|
||||
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||
- workspace.py — copy the operator workspace into the guest
|
||||
No modules remain in this subpackage. Workspace copying now runs
|
||||
through `BottleBackend.provision_workspace` against the running
|
||||
bottle for every backend.
|
||||
"""
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Install the per-bottle egress MITM CA into the smolmachines
|
||||
guest's trust store (PRD 0023 chunk 4d).
|
||||
|
||||
Mirrors `backend.docker.provision.ca`: copy the egress CA to
|
||||
Debian's `/usr/local/share/ca-certificates/` path,
|
||||
`update-ca-certificates` to rebuild the trust bundle, and log the
|
||||
fingerprint once.
|
||||
|
||||
`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 Bottle, ExecResult
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
_SIGKILL_EXIT = 128 + 9
|
||||
|
||||
|
||||
def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> 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)
|
||||
|
||||
bottle.cp_in(str(cert_host_path), 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(bottle)
|
||||
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(bottle)
|
||||
|
||||
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 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(bottle: Bottle) -> ExecResult:
|
||||
# chown + chmod + update-ca-certificates + bundle
|
||||
# verification run in one exec so we only pay one
|
||||
# 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 bottle.exec(
|
||||
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}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
# 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,133 +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 shlex
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ....git_gate import git_gate_render_gitconfig
|
||||
from ....log import info
|
||||
from ... import Bottle
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||
"""Set up git inside the guest. Runs all three subcases; each
|
||||
no-ops when its condition isn't met."""
|
||||
_provision_cwd_git(plan, bottle)
|
||||
_provision_git_gate_config(plan, bottle)
|
||||
_provision_git_user(plan, bottle)
|
||||
|
||||
|
||||
def _provision_cwd_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> 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} -> {bottle.name}:{guest_workspace_git}")
|
||||
# mkdir -p the workspace dir so cp_in lands the .git
|
||||
# directly there even on first-time bottles.
|
||||
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
|
||||
bottle.cp_in(host_git, guest_workspace_git)
|
||||
# cp_in lands files as root; the agent runs as node so
|
||||
# the workspace tree must be chowned over.
|
||||
bottle.exec(
|
||||
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
def _provision_git_gate_config(
|
||||
plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||
) -> None:
|
||||
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
|
||||
rules. No-op when the bottle has no `git` entries."""
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not manifest_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(
|
||||
manifest_bottle.git, plan.agent_git_gate_host, scheme="http",
|
||||
)
|
||||
|
||||
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||
# Stage the file under the plan's stage_dir so cp_in
|
||||
# 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(manifest_bottle.git)} insteadOf rule(s)")
|
||||
bottle.cp_in(str(config_file), guest_gitconfig)
|
||||
bottle.exec(
|
||||
f"chown node:node {shlex.quote(guest_gitconfig)} && "
|
||||
f"chmod 644 {shlex.quote(guest_gitconfig)}",
|
||||
user="root",
|
||||
)
|
||||
|
||||
|
||||
def _provision_git_user(
|
||||
plan: SmolmachinesBottlePlan, bottle: Bottle,
|
||||
) -> 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`.
|
||||
|
||||
SmolmachinesBottle.exec(user="node") automatically sets
|
||||
HOME=/home/node so --global writes to /home/node/.gitconfig."""
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
gu = manifest_bottle.git_user
|
||||
if gu.is_empty():
|
||||
return
|
||||
if gu.name:
|
||||
info(f"git config --global user.name = {gu.name!r}")
|
||||
bottle.exec(
|
||||
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||
user="node",
|
||||
)
|
||||
if gu.email:
|
||||
info(f"git config --global user.email = {gu.email!r}")
|
||||
bottle.exec(
|
||||
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||
user="node",
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Copy the operator workspace into a smolmachines guest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
|
||||
from ....log import info
|
||||
from ... import Bottle
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> 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} -> {bottle.name}:{workspace.guest_path}")
|
||||
bottle.exec(
|
||||
f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
|
||||
user="root",
|
||||
)
|
||||
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
||||
bottle.exec(
|
||||
f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
|
||||
user="root",
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .. import BottleSpec
|
||||
from ...manifest import Manifest
|
||||
from ...env import ResolvedEnv
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from .bottle_plan import SmolmachinesBottlePlan
|
||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
||||
|
||||
def preflight() -> None:
|
||||
smolmachines_preflight()
|
||||
|
||||
|
||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
# 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.
|
||||
return {
|
||||
**resolved_env.literals,
|
||||
**resolved_env.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",
|
||||
}
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
git_gate_plan: GitGatePlan,
|
||||
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 specific setup ====
|
||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
||||
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
bundle_subnet=subnet,
|
||||
bundle_gateway=gateway,
|
||||
bundle_ip=bundle_ip,
|
||||
guest_env=agent_provision_plan.guest_env,
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
agent_provision=agent_provision_plan,
|
||||
)
|
||||
@@ -25,6 +25,7 @@ smolvm binary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
@@ -94,6 +95,16 @@ def pack_create(image: str, output: Path) -> None:
|
||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
||||
|
||||
|
||||
def pack_create_from_vm(name: str, output: Path) -> None:
|
||||
"""`smolvm pack create --from-vm <name> -o <output>`.
|
||||
|
||||
Snapshots an existing persistent VM into a pack artifact. As
|
||||
with `pack_create`, smolvm writes a launcher at `output` and the
|
||||
bootable sidecar at `output.smolmachine`.
|
||||
"""
|
||||
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
|
||||
|
||||
|
||||
# --- Machine lifecycle ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -143,6 +154,21 @@ def machine_create(
|
||||
_smolvm(*args)
|
||||
|
||||
|
||||
def machine_is_running(name: str) -> bool:
|
||||
"""Return True if the named VM is in the 'running' state."""
|
||||
result = _smolvm("machine", "ls", "--json", check=False)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
try:
|
||||
machines = json.loads(result.stdout or "[]")
|
||||
except ValueError:
|
||||
return False
|
||||
return any(
|
||||
isinstance(m, dict) and m.get("name") == name and m.get("state") == "running"
|
||||
for m in machines
|
||||
)
|
||||
|
||||
|
||||
def machine_start(name: str) -> None:
|
||||
"""`smolvm machine start --name NAME`."""
|
||||
_smolvm("machine", "start", "--name", name)
|
||||
|
||||
@@ -21,7 +21,9 @@ def smolmachines_preflight() -> None:
|
||||
die(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh"
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
||||
"To use the legacy Docker backend instead, set "
|
||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Terminal escape-sequence helpers shared across all bottle backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
|
||||
|
||||
# color name → (normal_idx, normal_hex, bright_idx, bright_hex, dark_bg_hex)
|
||||
# OSC 4 sets indexed palette entries (affects syntax-highlighted code and any
|
||||
# TUI content that uses indexed colors). dark_bg_hex is used for OSC 11
|
||||
# (default background) — a very dark tint that's visible even when the TUI
|
||||
# uses true/24-bit colors for its own chrome, which would otherwise bypass
|
||||
# the palette entirely.
|
||||
_COLORS: dict[str, tuple[int, str, int, str, str]] = {
|
||||
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
|
||||
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
|
||||
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
|
||||
"blue": (12, "#3498db", 4, "#2471a3", "#080820"),
|
||||
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
|
||||
}
|
||||
|
||||
# OSC 104 resets all indexed palette entries; OSC 111 resets default background.
|
||||
_RESET_PRINTF = "printf '\\033]104\\007\\033]111\\007'"
|
||||
|
||||
|
||||
def palette_printf(color: str) -> str:
|
||||
"""Shell `printf` command that emits OSC 4 + OSC 11 to tint the terminal
|
||||
for *color*: sets the normal/bright palette entries AND the default
|
||||
background to a dark shade of that color. Returns '' if unknown."""
|
||||
entry = _COLORS.get(color)
|
||||
if not entry:
|
||||
return ""
|
||||
n_idx, n_hex, b_idx, b_hex, bg_hex = entry
|
||||
seq = (
|
||||
f"\\033]4;{n_idx};{n_hex}\\007"
|
||||
f"\\033]4;{b_idx};{b_hex}\\007"
|
||||
f"\\033]11;{bg_hex}\\007"
|
||||
)
|
||||
return f"printf '{seq}'"
|
||||
|
||||
|
||||
def exec_shell_script(
|
||||
agent_argv: list[str],
|
||||
terminal_title: str = "",
|
||||
terminal_color: str = "",
|
||||
) -> str | None:
|
||||
"""Build a shell script string that optionally sets the terminal
|
||||
title and/or palette before running *agent_argv*, and resets the
|
||||
palette + background on exit. Returns None when no decoration is
|
||||
needed — callers should run *agent_argv* directly in that case."""
|
||||
title_cmd = (
|
||||
f"printf '\\033]0;%s\\007' {shlex.quote(terminal_title)}"
|
||||
if terminal_title else ""
|
||||
)
|
||||
pal_cmd = palette_printf(terminal_color)
|
||||
|
||||
if not title_cmd and not pal_cmd:
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
if title_cmd:
|
||||
parts.append(title_cmd)
|
||||
if pal_cmd:
|
||||
parts.append(pal_cmd)
|
||||
parts.append(shlex.join(agent_argv))
|
||||
parts.append(_RESET_PRINTF)
|
||||
else:
|
||||
# No palette change — exec so the agent replaces the shell.
|
||||
parts.append(f"exec {shlex.join(agent_argv)}")
|
||||
|
||||
return "; ".join(parts)
|
||||
@@ -37,13 +37,13 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from ... import supervise as _supervise
|
||||
from . import util as docker_mod
|
||||
from . import supervise as _supervise
|
||||
|
||||
|
||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||
_STATE_SUBDIR = "state"
|
||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||
_TRANSCRIPT_SUBDIR = "transcript"
|
||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||
# live here so chunk 3's `docker compose up` can find them at stable
|
||||
@@ -82,6 +82,7 @@ def bottle_identity(agent_name: str) -> str:
|
||||
To continue an existing bottle's state, use the recorded
|
||||
identity from BottleMetadata via `cli.py resume <identity>`,
|
||||
not this function."""
|
||||
from .backend.docker import util as docker_mod
|
||||
slug = docker_mod.slugify(agent_name)
|
||||
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
|
||||
return f"{slug}-{suffix}"
|
||||
@@ -109,6 +110,8 @@ class BottleMetadata:
|
||||
# for state dirs written before PRD 0040; callers default to "docker"
|
||||
# for backward compatibility.
|
||||
backend: str = ""
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
|
||||
|
||||
def metadata_path(identity: str) -> Path:
|
||||
@@ -144,6 +147,8 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
||||
started_at=str(raw_typed.get("started_at", "")),
|
||||
compose_project=str(raw_typed.get("compose_project", "")),
|
||||
backend=str(raw_typed.get("backend", "")),
|
||||
label=str(raw_typed.get("label", "")),
|
||||
color=str(raw_typed.get("color", "")),
|
||||
)
|
||||
|
||||
|
||||
@@ -175,6 +180,32 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def committed_image_path(identity: str) -> Path:
|
||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
||||
|
||||
|
||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
||||
"""Persist the committed image tag for `identity`. The next
|
||||
`cli.py resume <identity>` will boot from this image instead of
|
||||
rebuilding from the Dockerfile."""
|
||||
path = committed_image_path(identity)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(image_tag.strip() + "\n")
|
||||
path.chmod(0o644)
|
||||
return path
|
||||
|
||||
|
||||
def read_committed_image(identity: str) -> str | None:
|
||||
"""Return the committed image tag for `identity`, or None if no
|
||||
commit has been recorded. Used by the Docker launch step to skip
|
||||
the Dockerfile build when a committed snapshot exists."""
|
||||
path = committed_image_path(identity)
|
||||
if not path.is_file():
|
||||
return None
|
||||
tag = path.read_text().strip()
|
||||
return tag or None
|
||||
|
||||
|
||||
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
|
||||
@@ -310,6 +341,7 @@ __all__ = [
|
||||
"bottle_state_dir",
|
||||
"cleanup_state",
|
||||
"clear_preserve_marker",
|
||||
"committed_image_path",
|
||||
"egress_state_dir",
|
||||
"git_gate_state_dir",
|
||||
"is_preserved",
|
||||
@@ -319,9 +351,11 @@ __all__ = [
|
||||
"per_bottle_dockerfile_path",
|
||||
"per_bottle_image_tag",
|
||||
"preserve_marker_path",
|
||||
"read_committed_image",
|
||||
"read_metadata",
|
||||
"supervise_state_dir",
|
||||
"transcript_snapshot_dir",
|
||||
"write_committed_image",
|
||||
"write_metadata",
|
||||
"write_per_bottle_dockerfile",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Main CLI dispatcher.
|
||||
|
||||
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
||||
Commands: cleanup, commit, edit, info, init, list, resume, start, supervise
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +12,7 @@ from ..manifest import ManifestError
|
||||
from ._common import PROG
|
||||
from . import list as _list_mod
|
||||
from .cleanup import cmd_cleanup
|
||||
from .commit import cmd_commit
|
||||
from .edit import cmd_edit
|
||||
from .info import cmd_info
|
||||
from .init import cmd_init
|
||||
@@ -23,6 +24,7 @@ cmd_list = _list_mod.cmd_list
|
||||
|
||||
COMMANDS = {
|
||||
"cleanup": cmd_cleanup,
|
||||
"commit": cmd_commit,
|
||||
"edit": cmd_edit,
|
||||
"info": cmd_info,
|
||||
"init": cmd_init,
|
||||
@@ -37,6 +39,7 @@ 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(" commit snapshot a running bottle's container state to a Docker image\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")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""commit: freeze a running bottle's state to a resumable artifact.
|
||||
|
||||
Docker bottles are committed to a local Docker image. Macos-container
|
||||
bottles are exported and rebuilt as a local Apple Container image.
|
||||
Smolmachines bottles are packed from the running VM into a
|
||||
`.smolmachine` artifact. The resulting reference is stored in
|
||||
per-bottle state so the next `./cli.py resume <slug>` boots from the
|
||||
snapshot instead of rebuilding from the Dockerfile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from ..backend import enumerate_active_agents
|
||||
from ..backend.freeze import CommitCancelled, get_freezer
|
||||
from ..bottle_state import read_metadata
|
||||
from ..log import die
|
||||
from ._common import PROG
|
||||
from . import tui
|
||||
|
||||
|
||||
def cmd_commit(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
||||
parser.add_argument(
|
||||
"slug",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=(
|
||||
"bottle slug from `cli.py list active` "
|
||||
"(omit to pick interactively)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
slug = args.slug
|
||||
if slug is None:
|
||||
active = enumerate_active_agents()
|
||||
if not active:
|
||||
die("no active bottles; start one with `./cli.py start`")
|
||||
choices = [a.slug for a in active]
|
||||
slug = tui.filter_select(choices, title="Select bottle to commit")
|
||||
if slug is None:
|
||||
return 0
|
||||
|
||||
metadata = read_metadata(slug)
|
||||
backend = metadata.backend if metadata else ""
|
||||
|
||||
try:
|
||||
get_freezer(backend).commit_slug(slug)
|
||||
except CommitCancelled:
|
||||
return 0
|
||||
return 0
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
|
||||
from ..log import info
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ def cmd_info(argv: list[str]) -> int:
|
||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
manifest.require_agent(args.name)
|
||||
names = ManifestIndex.resolve(USER_CWD)
|
||||
names.require_agent(args.name)
|
||||
manifest = names.load_for_agent(args.name)
|
||||
|
||||
agent = manifest.agents[args.name]
|
||||
bottle = manifest.bottle_for(args.name)
|
||||
agent = manifest.agent
|
||||
bottle = manifest.bottle
|
||||
env_names = list(bottle.env.keys())
|
||||
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
|
||||
|
||||
@@ -31,7 +32,7 @@ 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)
|
||||
identity = manifest.git_identity_summary()
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
if bottle.git:
|
||||
|
||||
+32
-8
@@ -3,12 +3,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ..backend import enumerate_active_agents
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD
|
||||
|
||||
_ANSI_COLOR_CODES: dict[str, str] = {
|
||||
"red": "\033[91m",
|
||||
"green": "\033[92m",
|
||||
"yellow": "\033[93m",
|
||||
"blue": "\033[94m",
|
||||
"magenta": "\033[95m",
|
||||
}
|
||||
_ANSI_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _ansi_label(text: str, color: str) -> str:
|
||||
if not color:
|
||||
return text
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
term = os.environ.get("TERM", "")
|
||||
if term in ("dumb", ""):
|
||||
return text
|
||||
code = _ANSI_COLOR_CODES.get(color)
|
||||
if not code:
|
||||
return text
|
||||
return f"{code}{text}{_ANSI_RESET}"
|
||||
|
||||
|
||||
def cmd_list(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
|
||||
@@ -16,8 +40,8 @@ def cmd_list(argv: list[str]) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.scope == "available":
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
for name in manifest.agents.keys():
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
for name in manifest.all_agent_names:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
@@ -27,11 +51,11 @@ def cmd_list(argv: list[str]) -> int:
|
||||
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.
|
||||
# One line per bottle: `<backend>\t<slug>\t<label>\t<services>`.
|
||||
# Tab-separated keeps the format stable for shell pipelines.
|
||||
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}")
|
||||
display_name = f"{b.label} ({b.agent_name})" if b.label else b.agent_name
|
||||
colored_name = _ansi_label(display_name, b.color)
|
||||
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
|
||||
return 0
|
||||
|
||||
@@ -18,9 +18,9 @@ from __future__ import annotations
|
||||
import argparse
|
||||
|
||||
from ..backend import BottleSpec
|
||||
from ..backend.docker.bottle_state import read_metadata
|
||||
from ..bottle_state import read_metadata
|
||||
from ..log import die
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD
|
||||
from .start import _launch_bottle
|
||||
|
||||
@@ -28,7 +28,6 @@ from .start import _launch_bottle
|
||||
def cmd_resume(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--remote-control", action="store_true")
|
||||
parser.add_argument(
|
||||
"identity",
|
||||
help="bottle identity from a prior `start` (see its session-end output)",
|
||||
@@ -42,7 +41,7 @@ def cmd_resume(argv: list[str]) -> int:
|
||||
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
||||
)
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
manifest.require_agent(metadata.agent_name)
|
||||
|
||||
spec = BottleSpec(
|
||||
@@ -56,6 +55,5 @@ def cmd_resume(argv: list[str]) -> int:
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=args.dry_run,
|
||||
remote_control=args.remote_control,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
+39
-27
@@ -20,18 +20,20 @@ from ..agent_provider import runtime_for
|
||||
from ..backend import (
|
||||
Bottle,
|
||||
BottleSpec,
|
||||
enumerate_active_agents,
|
||||
get_bottle_backend,
|
||||
known_backend_names,
|
||||
)
|
||||
from ..backend.docker import util as docker_mod
|
||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||
from ..backend.docker.bottle_state import (
|
||||
from ..bottle_state import (
|
||||
cleanup_state,
|
||||
is_preserved,
|
||||
mark_preserved,
|
||||
)
|
||||
from ..backend.docker.capability_apply import snapshot_transcript
|
||||
# from ..backend.docker.capability_apply import snapshot_transcript
|
||||
from ..log import info
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD, read_tty_line
|
||||
from . import tui
|
||||
|
||||
@@ -39,15 +41,14 @@ from . import tui
|
||||
def cmd_start(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
|
||||
parser.add_argument("--remote-control", action="store_true")
|
||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
default=None,
|
||||
help=(
|
||||
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
|
||||
"or 'docker'). Overrides the env var when set."
|
||||
"or host auto-selection). Overrides the env var when set."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -60,36 +61,33 @@ def cmd_start(argv: list[str]) -> int:
|
||||
|
||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
|
||||
agent_name: str | None = args.name
|
||||
if agent_name is None:
|
||||
agent_name = tui.filter_select(
|
||||
sorted(manifest.agents.keys()),
|
||||
manifest.all_agent_names,
|
||||
title="Select agent",
|
||||
)
|
||||
if agent_name is None:
|
||||
return 0
|
||||
|
||||
backend_name: str | None = args.backend
|
||||
if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ:
|
||||
backend_name = tui.filter_select(
|
||||
list(known_backend_names()),
|
||||
title="Select backend",
|
||||
)
|
||||
if backend_name is None:
|
||||
return 0
|
||||
|
||||
label, color = tui.name_color_modal(default_label=agent_name)
|
||||
label, color = _resolve_unique_label(label, color)
|
||||
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name=agent_name,
|
||||
copy_cwd=args.cwd,
|
||||
user_cwd=USER_CWD,
|
||||
label=label,
|
||||
color=color,
|
||||
)
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
remote_control=args.remote_control,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
@@ -110,8 +108,8 @@ def prepare_with_preflight(
|
||||
injected callable, prompt y/N via the injected callable.
|
||||
|
||||
`backend_name` selects which backend prepares the plan
|
||||
(`None` → `$BOT_BOTTLE_BACKEND` → `docker`). The CLI passes
|
||||
whatever `--backend` resolved to.
|
||||
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
|
||||
passes whatever `--backend` resolved to.
|
||||
|
||||
Returns `(plan, identity)`. `plan` is None on dry-run or
|
||||
operator-N, but `identity` is set as soon as `backend.prepare`
|
||||
@@ -134,8 +132,9 @@ def prepare_with_preflight(
|
||||
|
||||
|
||||
def attach_agent(
|
||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
||||
bottle: Bottle, *, resume: bool = False,
|
||||
agent_provider_template: str = "claude",
|
||||
startup_args: tuple[str, ...] = (),
|
||||
) -> int:
|
||||
"""Run the selected provider CLI inside `bottle` as an
|
||||
interactive session. Blocks until the session ends; returns the
|
||||
@@ -152,8 +151,7 @@ def attach_agent(
|
||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||
)
|
||||
agent_args = list(runtime.bypass_args)
|
||||
if remote_control:
|
||||
agent_args.extend(runtime.remote_control_args)
|
||||
agent_args.extend(startup_args)
|
||||
if resume:
|
||||
agent_args.extend(runtime.resume_args)
|
||||
return bottle.exec_agent(agent_args, tty=True)
|
||||
@@ -168,7 +166,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None:
|
||||
# instead of relying on each agent's transcript layout.
|
||||
if not identity:
|
||||
return
|
||||
snapshot_transcript(identity)
|
||||
# snapshot_transcript(identity)
|
||||
if exit_code != 0:
|
||||
mark_preserved(identity)
|
||||
|
||||
@@ -192,6 +190,21 @@ def _identity_from_plan(plan: object) -> str:
|
||||
return getattr(plan, "slug", "")
|
||||
|
||||
|
||||
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
||||
"""Re-prompt with a disclaimer until the label's slug is not already
|
||||
in use among running bottles. Passes through unchanged when no
|
||||
collision is found on the first check."""
|
||||
while True:
|
||||
slug_candidate = docker_mod.slugify(label)
|
||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||
if slug_candidate not in active_slugs:
|
||||
return label, color
|
||||
label, color = tui.name_color_modal(
|
||||
default_label=label,
|
||||
disclaimer=f'"{label}" is already in use',
|
||||
)
|
||||
|
||||
|
||||
def _text_prompt_yes() -> bool:
|
||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
||||
controlling tty via stderr prompt + tty-line read."""
|
||||
@@ -201,9 +214,9 @@ def _text_prompt_yes() -> bool:
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
|
||||
|
||||
def _text_render_preflight(*, remote_control: bool):
|
||||
def _text_render_preflight():
|
||||
def _render(plan: DockerBottlePlan) -> None:
|
||||
plan.print(remote_control=remote_control)
|
||||
plan.print()
|
||||
return _render
|
||||
|
||||
|
||||
@@ -211,7 +224,6 @@ def _launch_bottle(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
dry_run: bool,
|
||||
remote_control: bool,
|
||||
backend_name: str | None = None,
|
||||
) -> int:
|
||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||
@@ -223,7 +235,7 @@ def _launch_bottle(
|
||||
plan, identity = prepare_with_preflight(
|
||||
spec,
|
||||
stage_dir=stage_dir,
|
||||
render_preflight=_text_render_preflight(remote_control=remote_control),
|
||||
render_preflight=_text_render_preflight(),
|
||||
prompt_yes=_text_prompt_yes,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
@@ -236,8 +248,8 @@ def _launch_bottle(
|
||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
exit_code = attach_agent(
|
||||
bottle,
|
||||
remote_control=remote_control,
|
||||
agent_provider_template=agent_provider_template,
|
||||
startup_args=plan.agent_provision.startup_args,
|
||||
)
|
||||
info(
|
||||
f"session ended (exit {exit_code}); "
|
||||
|
||||
+97
-33
@@ -2,9 +2,9 @@
|
||||
act on them (approve / modify / reject).
|
||||
|
||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||
approval handlers wire to the per-tool remediation engines:
|
||||
PRD 0014 (egress) writes routes.yaml + SIGHUPs egress; PRD 0016
|
||||
(capability) rebuilds the bottle Dockerfile.
|
||||
approval handler wires to PRD 0016 (capability-block), which rebuilds
|
||||
the bottle Dockerfile. Egress proposals are queued for operator review
|
||||
as full routes.yaml updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,13 +21,27 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .. import supervise as _supervise
|
||||
from ..backend.docker.bottle_state import read_metadata
|
||||
from ..backend.docker.capability_apply import (
|
||||
CapabilityApplyError,
|
||||
apply_capability_change,
|
||||
from ..bottle_state import read_metadata
|
||||
# from ..backend.docker.capability_apply import (
|
||||
# CapabilityApplyError,
|
||||
# apply_capability_change,
|
||||
# )
|
||||
from ..backend.docker.egress_apply import (
|
||||
EgressApplyError,
|
||||
applicator as _docker_applicator,
|
||||
)
|
||||
from ..backend.macos_container.egress_apply import (
|
||||
applicator as _macos_applicator,
|
||||
)
|
||||
from ..backend.smolmachines.egress_apply import (
|
||||
applicator as _smolmachines_applicator,
|
||||
)
|
||||
from ..backend.docker.egress_apply import EgressApplyError, add_route
|
||||
from ..log import Die, error, info
|
||||
|
||||
|
||||
class CapabilityApplyError(RuntimeError):
|
||||
"""Placeholder while capability_apply is disabled."""
|
||||
|
||||
from ..supervise import (
|
||||
COMPONENT_FOR_TOOL,
|
||||
AuditEntry,
|
||||
@@ -37,7 +51,10 @@ from ..supervise import (
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
archive_proposal,
|
||||
list_pending_proposals,
|
||||
render_diff,
|
||||
@@ -49,6 +66,11 @@ from ._common import PROG
|
||||
|
||||
_REFRESH_INTERVAL_MS = 1000
|
||||
|
||||
# Proposal tools whose payload is a read-only report, not a file the operator
|
||||
# edits: modify is unavailable and approval requires a recorded reason for the
|
||||
# audit trail.
|
||||
_REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueuedProposal:
|
||||
@@ -61,7 +83,17 @@ class QueuedProposal:
|
||||
# Errors any remediation engine may raise. Caught by the TUI key
|
||||
# handlers and surfaced in the status line so a failed apply keeps
|
||||
# the proposal pending rather than crashing curses.
|
||||
ApplyError = (EgressApplyError, CapabilityApplyError)
|
||||
ApplyError = (CapabilityApplyError, EgressApplyError)
|
||||
|
||||
|
||||
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
||||
meta = read_metadata(slug)
|
||||
backend = meta.backend if meta is not None else ""
|
||||
if backend == "macos-container":
|
||||
return _macos_applicator.apply_routes_change(slug, content)
|
||||
if backend == "smolmachines":
|
||||
return _smolmachines_applicator.apply_routes_change(slug, content)
|
||||
return _docker_applicator.apply_routes_change(slug, content)
|
||||
|
||||
|
||||
def discover_pending() -> list[QueuedProposal]:
|
||||
@@ -82,9 +114,7 @@ def discover_pending() -> list[QueuedProposal]:
|
||||
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
||||
"""Status-line text after a successful approval."""
|
||||
base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
|
||||
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
|
||||
return base
|
||||
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
|
||||
|
||||
|
||||
def _detail_lines(
|
||||
@@ -115,6 +145,10 @@ def _detail_lines(
|
||||
def _suffix_for_tool(tool: str) -> str:
|
||||
if tool == TOOL_CAPABILITY_BLOCK:
|
||||
return ".dockerfile"
|
||||
if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||
return ".yaml"
|
||||
if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW):
|
||||
return ".txt"
|
||||
return ".txt"
|
||||
|
||||
|
||||
@@ -132,20 +166,21 @@ def approve(
|
||||
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
||||
|
||||
diff_before, diff_after = "", ""
|
||||
if qp.proposal.tool == TOOL_EGRESS_BLOCK:
|
||||
diff_before, diff_after = add_route(
|
||||
qp.proposal.bottle_slug, file_to_apply,
|
||||
)
|
||||
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
_meta = read_metadata(qp.proposal.bottle_slug)
|
||||
if _meta is not None and not _meta.compose_project:
|
||||
raise CapabilityApplyError(
|
||||
"capability-block remediation is not supported for smolmachines "
|
||||
"bottles. Reject this proposal or handle the capability change "
|
||||
"manually, then restart the bottle."
|
||||
)
|
||||
diff_before, diff_after = apply_capability_change(
|
||||
qp.proposal.bottle_slug, file_to_apply,
|
||||
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
# _meta = read_metadata(qp.proposal.bottle_slug)
|
||||
# if _meta is not None and not _meta.compose_project:
|
||||
# raise CapabilityApplyError(
|
||||
# "capability-block remediation is not supported for smolmachines "
|
||||
# "bottles. Reject this proposal or handle the capability change "
|
||||
# "manually, then restart the bottle."
|
||||
# )
|
||||
# diff_before, diff_after = apply_capability_change(
|
||||
# qp.proposal.bottle_slug, file_to_apply,
|
||||
# )
|
||||
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||
diff_before, diff_after = apply_routes_change(
|
||||
qp.proposal.bottle_slug,
|
||||
file_to_apply,
|
||||
)
|
||||
|
||||
response = Response(
|
||||
@@ -175,6 +210,23 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
||||
|
||||
|
||||
def _approve_from_tui(
|
||||
stdscr: "curses._CursesWindow", # type: ignore
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
final_file: str | None = None,
|
||||
notes: str = "",
|
||||
) -> str:
|
||||
"""Approve from curses, prompting for any tool-specific audit note."""
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS and final_file is None:
|
||||
notes = _prompt(stdscr, "allow reason (false positive / legitimately needed): ")
|
||||
if not notes:
|
||||
return "approve aborted (empty reason)"
|
||||
approve(qp, final_file=final_file, notes=notes)
|
||||
verb = "modified+approved" if final_file is not None else "approved"
|
||||
return _approval_status(qp, verb)
|
||||
|
||||
|
||||
def _write_audit(
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
@@ -246,7 +298,10 @@ def cmd_supervise(argv: list[str]) -> int:
|
||||
return e.code if isinstance(e.code, int) else 1
|
||||
except Exception as e: # noqa: W0718 — catch supervise crash for logging
|
||||
log_path = _write_crash_log(e)
|
||||
error(f"supervise crashed: {type(e).__name__}: {e}")
|
||||
error(
|
||||
f"supervise crashed: {type(e).__name__}: {e}",
|
||||
context={"error_type": type(e).__name__, "crash_log": str(log_path)},
|
||||
)
|
||||
error(f"full traceback written to {log_path}")
|
||||
return 1
|
||||
return 0
|
||||
@@ -358,18 +413,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
approve(qp)
|
||||
status_line = _approval_status(qp, "approved")
|
||||
status_line = _approve_from_tui(stdscr, qp)
|
||||
except ApplyError as e:
|
||||
status_line = f"apply failed: {e}"
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||
status_line = f"modify unavailable for {qp.proposal.tool}"
|
||||
continue
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is None:
|
||||
status_line = "modify aborted (no change)"
|
||||
else:
|
||||
try:
|
||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||
status_line = _approval_status(qp, "modified+approved")
|
||||
status_line = _approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError as e:
|
||||
status_line = f"apply failed: {e}"
|
||||
elif key == ord("r"):
|
||||
@@ -467,15 +526,20 @@ def _detail_view(
|
||||
offset = max(0, len(lines) - 1)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
approve(qp)
|
||||
_approve_from_tui(stdscr, qp)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||
return
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is not None:
|
||||
try:
|
||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||
_approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Exposed surface:
|
||||
|
||||
filter_select(items, *, title="", tty_path="/dev/tty") -> str | None
|
||||
name_color_modal(default_label, *, tty_path="/dev/tty") -> (str, str)
|
||||
|
||||
Opens /dev/tty directly so the picker works even when stdout/stdin are
|
||||
redirected. Returns the selected item or None on cancel.
|
||||
@@ -218,3 +219,219 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
|
||||
screen.addstr(row, col, text, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# name_color_modal — two-step label + color picker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ANSI_COLORS = [
|
||||
"red", "green", "yellow", "blue", "magenta",
|
||||
]
|
||||
|
||||
_CURSES_COLOR_MAP: dict[str, int] = {
|
||||
"red": curses.COLOR_RED,
|
||||
"green": curses.COLOR_GREEN,
|
||||
"yellow": curses.COLOR_YELLOW,
|
||||
"blue": curses.COLOR_BLUE,
|
||||
"magenta": curses.COLOR_MAGENTA,
|
||||
}
|
||||
|
||||
_COLOR_NONE = "(none)"
|
||||
|
||||
|
||||
def name_color_modal(
|
||||
default_label: str,
|
||||
*,
|
||||
disclaimer: str = "",
|
||||
tty_path: str = "/dev/tty",
|
||||
) -> tuple[str, str]:
|
||||
"""Present a two-step curses modal: first edit the agent label,
|
||||
then optionally pick a color.
|
||||
|
||||
``disclaimer`` is shown below the input field — use it to surface
|
||||
an error from a previous attempt (e.g. name already in use).
|
||||
|
||||
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
||||
color name strings or ``""`` for no color. Falls back to
|
||||
``(default_label, "")`` on any error (terminal too small, not a tty).
|
||||
"""
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
|
||||
except OSError:
|
||||
return default_label, ""
|
||||
|
||||
try:
|
||||
fd_dup = os.dup(tty_fd.fileno())
|
||||
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
return default_label, ""
|
||||
finally:
|
||||
tty_fd.close()
|
||||
|
||||
|
||||
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
|
||||
import io
|
||||
orig_stdin = sys.__stdin__
|
||||
orig_stdout = sys.__stdout__
|
||||
try:
|
||||
tty_text = io.TextIOWrapper(io.FileIO(tty_fd, mode="r+"), write_through=True)
|
||||
sys.__stdin__ = tty_text # type: ignore[assignment]
|
||||
sys.__stdout__ = tty_text # type: ignore[assignment]
|
||||
os.environ.setdefault("TERM", "xterm-256color")
|
||||
|
||||
screen = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
screen.keypad(True)
|
||||
try:
|
||||
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
||||
color = _color_step(screen, label)
|
||||
finally:
|
||||
screen.keypad(False)
|
||||
curses.nocbreak()
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
finally:
|
||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||
sys.__stdout__ = orig_stdout # type: ignore[assignment]
|
||||
return label, color
|
||||
|
||||
|
||||
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
|
||||
"""Step 1: edit the label. First printable key replaces the
|
||||
pre-fill; subsequent keys append. Enter confirms."""
|
||||
text = default_label
|
||||
replaced = False # True once the user has typed their first char
|
||||
|
||||
while True:
|
||||
_render_label(screen, text, disclaimer=disclaimer)
|
||||
try:
|
||||
key = screen.getch()
|
||||
except KeyboardInterrupt:
|
||||
return default_label
|
||||
|
||||
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
||||
return text.strip() or default_label
|
||||
|
||||
if key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
|
||||
if replaced:
|
||||
text = text[:-1]
|
||||
else:
|
||||
text = ""
|
||||
replaced = True
|
||||
|
||||
elif 32 <= key <= 126:
|
||||
if not replaced:
|
||||
text = chr(key)
|
||||
replaced = True
|
||||
else:
|
||||
text += chr(key)
|
||||
|
||||
|
||||
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
||||
screen.erase()
|
||||
rows, cols = screen.getmaxyx()
|
||||
sep = "─" * min(cols - 1, 40)
|
||||
_addstr_safe(screen, 0, 0, "Name agent", curses.A_BOLD)
|
||||
_addstr_safe(screen, 1, 0, sep)
|
||||
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
||||
_addstr_safe(screen, 3, 0, sep)
|
||||
row = 4
|
||||
if disclaimer and rows > row + 1:
|
||||
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
|
||||
row += 1
|
||||
if rows > row + 1:
|
||||
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
||||
screen.refresh()
|
||||
|
||||
|
||||
def _color_step(screen: Any, confirmed_label: str) -> str:
|
||||
"""Step 2: pick a color from the list, or skip."""
|
||||
items = [_COLOR_NONE] + _ANSI_COLORS
|
||||
cursor = 0
|
||||
|
||||
# Initialise color pairs once; index 0 = none, 1..16 = palette.
|
||||
color_attrs = _init_color_pairs()
|
||||
|
||||
while True:
|
||||
_render_color(screen, items, cursor, confirmed_label, color_attrs)
|
||||
try:
|
||||
key = screen.getch()
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
|
||||
if key in (ord("q"), _KEY_ESC):
|
||||
return ""
|
||||
|
||||
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
||||
chosen = items[cursor]
|
||||
return "" if chosen == _COLOR_NONE else chosen
|
||||
|
||||
if key in (curses.KEY_UP, ord("k")) and cursor > 0:
|
||||
cursor -= 1
|
||||
elif key in (curses.KEY_DOWN, ord("j")) and cursor < len(items) - 1:
|
||||
cursor += 1
|
||||
|
||||
|
||||
def _init_color_pairs() -> dict[str, int]:
|
||||
"""Return {color_name: curses_attr} for the palette items."""
|
||||
attrs: dict[str, int] = {_COLOR_NONE: curses.A_NORMAL}
|
||||
try:
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
pair_idx = 2 # pair 1 reserved for other uses
|
||||
for name in _ANSI_COLORS:
|
||||
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
|
||||
try:
|
||||
curses.init_pair(pair_idx, fg, -1)
|
||||
attr = curses.color_pair(pair_idx) | curses.A_BOLD
|
||||
attrs[name] = attr
|
||||
pair_idx += 1
|
||||
except curses.error:
|
||||
attrs[name] = curses.A_NORMAL
|
||||
except curses.error:
|
||||
for name in _ANSI_COLORS:
|
||||
attrs[name] = curses.A_NORMAL
|
||||
return attrs
|
||||
|
||||
|
||||
def _render_color(
|
||||
screen: Any,
|
||||
items: list[str],
|
||||
cursor: int,
|
||||
confirmed_label: str,
|
||||
color_attrs: dict[str, int],
|
||||
) -> None:
|
||||
screen.erase()
|
||||
rows, cols = screen.getmaxyx()
|
||||
sep = "─" * min(cols - 1, 40)
|
||||
_addstr_safe(screen, 0, 0, "Name agent", curses.A_BOLD)
|
||||
_addstr_safe(screen, 1, 0, sep)
|
||||
_addstr_safe(screen, 2, 0, confirmed_label[:cols - 1])
|
||||
_addstr_safe(screen, 3, 0, sep)
|
||||
_addstr_safe(screen, 4, 0, "Color (optional)", curses.A_BOLD)
|
||||
|
||||
list_start = 5
|
||||
list_rows = rows - list_start - 2
|
||||
scroll = max(0, cursor - list_rows + 1)
|
||||
visible = items[scroll: scroll + list_rows]
|
||||
|
||||
for idx, name in enumerate(visible):
|
||||
abs_idx = scroll + idx
|
||||
row = list_start + idx
|
||||
if row >= rows - 2:
|
||||
break
|
||||
prefix = "> " if abs_idx == cursor else " "
|
||||
attr = color_attrs.get(name, curses.A_NORMAL)
|
||||
if abs_idx == cursor:
|
||||
attr |= curses.A_REVERSE
|
||||
_addstr_safe(screen, row, 0, (prefix + name)[:cols - 1], attr)
|
||||
|
||||
_addstr_safe(screen, rows - 2, 0, sep)
|
||||
_addstr_safe(
|
||||
screen, rows - 1, 0,
|
||||
"[↑↓/jk] move [Enter] select [Esc/q] skip",
|
||||
curses.A_DIM,
|
||||
)
|
||||
screen.refresh()
|
||||
|
||||
@@ -16,21 +16,27 @@ FROM node:22-slim
|
||||
# features (status checks, commits, PR creation) — without git in the
|
||||
# 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
|
||||
# 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 egress's bumped TLS without the agent needing local DNS.
|
||||
# clarity in case the base ever drops it. curl is here so any
|
||||
# HTTPS_PROXY-aware tool (curl itself, plus anything that shells out
|
||||
# to it) works against egress'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 curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App-specific deps. Python isn't required by claude-code itself
|
||||
# (claude-code is a Node CLI), but is convenient for the agent to
|
||||
# shell out to for ad-hoc scripts. Kept on its own layer so it can
|
||||
# be moved to a downstream image if the base ever needs to shrink.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install claude-code globally. Pinned to the version verified in the v1
|
||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||
# up new behavior.
|
||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
|
||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Run as a non-root user. The node image already provides a `node` user
|
||||
@@ -17,9 +17,12 @@ from typing import TYPE_CHECKING
|
||||
from ...agent_provider import (
|
||||
AgentProvider,
|
||||
AgentProviderRuntime,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
provider_startup_args,
|
||||
)
|
||||
from ...backend.docker import util as docker_mod
|
||||
from ...egress import EgressRoute
|
||||
from ...log import die, info, warn
|
||||
|
||||
@@ -28,8 +31,6 @@ if TYPE_CHECKING:
|
||||
from ...backend import Bottle, BottlePlan
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
@@ -40,15 +41,56 @@ def _skills_dir(guest_home: str) -> str:
|
||||
def _prompt_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
|
||||
_STATUS_LINE_COLORS = {
|
||||
"red": "\033[91m",
|
||||
"green": "\033[92m",
|
||||
"yellow": "\033[93m",
|
||||
"blue": "\033[94m",
|
||||
"magenta": "\033[95m",
|
||||
}
|
||||
|
||||
_CLAUDE_THEME_COLORS = {
|
||||
"red": "redBright",
|
||||
"green": "greenBright",
|
||||
"yellow": "yellowBright",
|
||||
"blue": "blueBright",
|
||||
"magenta": "magentaBright",
|
||||
}
|
||||
|
||||
|
||||
def _status_line_script(label: str, color: str) -> str:
|
||||
if not label:
|
||||
return "#!/bin/sh\nprintf '\\n'\n"
|
||||
label_q = shlex.quote(label)
|
||||
if color and color in _STATUS_LINE_COLORS:
|
||||
return (
|
||||
"#!/bin/sh\n"
|
||||
f"printf '%b%s%b\\n' '{_STATUS_LINE_COLORS[color]}' {label_q} '\\033[0m'\n"
|
||||
)
|
||||
return f"#!/bin/sh\nprintf '%s\\n' {label_q}\n"
|
||||
|
||||
|
||||
def _custom_theme_payload(color: str) -> dict[str, object] | None:
|
||||
theme_color = _CLAUDE_THEME_COLORS.get(color)
|
||||
if not theme_color:
|
||||
return None
|
||||
return {
|
||||
"name": f"Bot-bottle {color}",
|
||||
"base": "dark",
|
||||
"overrides": {
|
||||
"claude": f"ansi:{theme_color}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_RUNTIME = AgentProviderRuntime(
|
||||
template="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",),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,34 +104,79 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
instance_name: str,
|
||||
prompt_file: Path,
|
||||
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 = "",
|
||||
label: str = "",
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
del forward_host_credentials, host_env # Codex-only knobs
|
||||
del forward_host_credentials, host_env
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
startup_args = provider_startup_args(provider_settings)
|
||||
guest_home = self.guest_home
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
|
||||
env_vars: dict[str, str] = {
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
}
|
||||
dirs = (
|
||||
AgentProvisionDir(f"{guest_home}/.claude"),
|
||||
AgentProvisionDir(f"{guest_home}/.claude/themes"),
|
||||
)
|
||||
claude_config = state_dir / "claude.json"
|
||||
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
|
||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
||||
claude_config.write_text(json.dumps({
|
||||
payload: dict[str, object] = {
|
||||
"hasCompletedOnboarding": True,
|
||||
"theme": "dark",
|
||||
"bypassPermissionsModeAccepted": True,
|
||||
"projects": claude_projects,
|
||||
}, indent=2) + "\n")
|
||||
}
|
||||
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
claude_config.chmod(0o600)
|
||||
files = (
|
||||
files = [
|
||||
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
|
||||
)
|
||||
]
|
||||
|
||||
claude_settings = state_dir / "claude-settings.json"
|
||||
claude_settings_payload: dict[str, object] = {}
|
||||
if label or color:
|
||||
statusline_script = state_dir / "claude-statusline.sh"
|
||||
statusline_script.write_text(_status_line_script(label, color))
|
||||
statusline_script.chmod(0o755)
|
||||
files.append(AgentProvisionFile(
|
||||
statusline_script,
|
||||
f"{guest_home}/.claude/statusline.sh",
|
||||
mode="755",
|
||||
))
|
||||
claude_settings_payload["statusLine"] = {
|
||||
"type": "command",
|
||||
"command": "~/.claude/statusline.sh",
|
||||
}
|
||||
theme_payload = _custom_theme_payload(color)
|
||||
if theme_payload is not None:
|
||||
theme_name = f"bot-bottle-{docker_mod.slugify(label or color)}"
|
||||
theme_file = state_dir / f"{theme_name}.json"
|
||||
theme_file.write_text(json.dumps(theme_payload, indent=2) + "\n")
|
||||
theme_file.chmod(0o644)
|
||||
files.append(AgentProvisionFile(
|
||||
theme_file,
|
||||
f"{guest_home}/.claude/themes/{theme_name}.json",
|
||||
))
|
||||
claude_settings_payload["theme"] = f"custom:{theme_name}"
|
||||
if claude_settings_payload:
|
||||
claude_settings.write_text(json.dumps(claude_settings_payload, indent=2) + "\n")
|
||||
claude_settings.chmod(0o600)
|
||||
files.append(AgentProvisionFile(
|
||||
claude_settings,
|
||||
f"{guest_home}/.claude/settings.json",
|
||||
))
|
||||
egress_routes = (EgressRoute(
|
||||
host="api.anthropic.com",
|
||||
auth_scheme="Bearer" if auth_token else "",
|
||||
@@ -100,15 +187,22 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||
|
||||
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
||||
return AgentProvisionPlan(
|
||||
template=_RUNTIME.template,
|
||||
command=_RUNTIME.command,
|
||||
prompt_mode=_RUNTIME.prompt_mode,
|
||||
image=_RUNTIME.image,
|
||||
dockerfile=dockerfile,
|
||||
guest_home=guest_home,
|
||||
instance_name=instance_name,
|
||||
prompt_file=prompt_file,
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
files=files,
|
||||
has_prompt=has_prompt,
|
||||
startup_args=startup_args,
|
||||
dirs=dirs,
|
||||
files=tuple(files),
|
||||
egress_routes=egress_routes,
|
||||
hidden_env_names=hidden_env_names,
|
||||
)
|
||||
@@ -119,7 +213,7 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
when the agent has no skills."""
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
@@ -148,8 +242,8 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||
user="root",
|
||||
)
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
return prompt_path if agent.prompt else None
|
||||
agent = plan.manifest.agent
|
||||
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Apply the claude-side declarative provision steps from
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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 for that user.
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App-specific deps. Python isn't required by codex itself
|
||||
# (codex is a Node CLI), but is convenient for the agent to shell
|
||||
# out to for ad-hoc scripts. Kept on its own layer so it can be
|
||||
# moved to a downstream image if the base ever needs to shrink.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||
|
||||
# Remote-control support requires the standalone Codex install layout
|
||||
# under ~/.codex/packages/standalone/current. The npm package can run
|
||||
# the TUI, but remote-control commands expect this installer-owned path.
|
||||
RUN mkdir -p /home/node/.codex \
|
||||
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
|
||||
CMD ["codex"]
|
||||
@@ -18,10 +18,11 @@ from ...agent_provider import (
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
AgentProvider,
|
||||
AgentProviderRuntime,
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
provider_startup_args,
|
||||
)
|
||||
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
||||
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||
@@ -32,8 +33,6 @@ if TYPE_CHECKING:
|
||||
from ...backend import Bottle, BottlePlan
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
|
||||
|
||||
@@ -48,15 +47,14 @@ def _skills_dir(guest_home: str) -> str:
|
||||
def _prompt_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
|
||||
_RUNTIME = AgentProviderRuntime(
|
||||
template="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=(),
|
||||
)
|
||||
|
||||
|
||||
@@ -70,15 +68,21 @@ class CodexAgentProvider(AgentProvider):
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
guest_home: str,
|
||||
instance_name: str,
|
||||
prompt_file: Path,
|
||||
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 = "",
|
||||
label: str = "",
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
del auth_token # Claude-only knob
|
||||
del auth_token, label, color
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
startup_args = provider_startup_args(provider_settings)
|
||||
guest_home = self.guest_home
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
|
||||
env_vars: dict[str, str] = {
|
||||
@@ -100,6 +104,11 @@ class CodexAgentProvider(AgentProvider):
|
||||
config_file.write_text(
|
||||
f'[projects."{toml_path}"]\n'
|
||||
'trust_level = "trusted"\n'
|
||||
"\n"
|
||||
"[tui]\n"
|
||||
'status_line = ["model-with-reasoning"]\n'
|
||||
'terminal_title = ["spinner", "project"]\n'
|
||||
'theme = "ansi"\n'
|
||||
)
|
||||
config_file.chmod(0o600)
|
||||
files.append(AgentProvisionFile(config_file, config_path))
|
||||
@@ -142,14 +151,20 @@ class CodexAgentProvider(AgentProvider):
|
||||
"guest, but Codex did not accept it"
|
||||
)))
|
||||
|
||||
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
||||
return AgentProvisionPlan(
|
||||
template=_RUNTIME.template,
|
||||
command=_RUNTIME.command,
|
||||
prompt_mode=_RUNTIME.prompt_mode,
|
||||
image=_RUNTIME.image,
|
||||
dockerfile=dockerfile,
|
||||
guest_home=guest_home,
|
||||
instance_name=instance_name,
|
||||
prompt_file=prompt_file,
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
has_prompt=has_prompt,
|
||||
startup_args=startup_args,
|
||||
dirs=tuple(dirs),
|
||||
files=tuple(files),
|
||||
pre_copy=tuple(pre_copy),
|
||||
@@ -164,7 +179,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
skills."""
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
@@ -193,8 +208,8 @@ class CodexAgentProvider(AgentProvider):
|
||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||
user="root",
|
||||
)
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
return prompt_path if agent.prompt else None
|
||||
agent = plan.manifest.agent
|
||||
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
"""Apply the codex-side declarative provision steps from
|
||||
@@ -248,8 +263,8 @@ class CodexAgentProvider(AgentProvider):
|
||||
return
|
||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||
r = bottle.exec(
|
||||
f"codex mcp add --transport http "
|
||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
|
||||
f"{shlex.quote(supervise_url)}",
|
||||
user="node",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -257,7 +272,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"codex mcp add --transport http supervise {supervise_url}"
|
||||
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
||||
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
||||
only stdlib `urllib.request` and `subprocess`."""
|
||||
only stdlib `urllib.request` and `subprocess`.
|
||||
|
||||
Required token permissions (Gitea "Applications" → "Generate Token"):
|
||||
- Repository: Read & Write
|
||||
Grants POST /api/v1/repos/{owner}/{repo}/keys (create deploy key)
|
||||
and DELETE /api/v1/repos/{owner}/{repo}/keys/{id} (revoke deploy key).
|
||||
No other scopes are needed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +19,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ...deploy_key_provisioner import DeployKeyProvisioner
|
||||
from ...deploy_key_provisioner import DeployKeyCollisionError, DeployKeyProvisioner
|
||||
|
||||
|
||||
class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||
@@ -65,6 +71,11 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||
body = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
_body = _read_error_body(exc)
|
||||
if exc.code == 422:
|
||||
raise DeployKeyCollisionError(
|
||||
f"deploy key collision for {owner_repo!r} "
|
||||
f"(title={title!r}): key title or content already registered — {_body}"
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"failed to create deploy key for {owner_repo}: "
|
||||
f"HTTP {exc.code} — {_body}"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# bot-bottle Pi provider image.
|
||||
#
|
||||
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
curl \
|
||||
fd-find \
|
||||
ripgrep \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
RUN mkdir -p /home/node/.pi/agent \
|
||||
/home/node/.pi/context-mode/sessions \
|
||||
/tmp/pi-subagents-uid-1000 \
|
||||
&& chown -R node:node /home/node/.pi /tmp \
|
||||
&& chmod -R u+rwX /tmp \
|
||||
&& chown root:root /tmp /var/tmp \
|
||||
&& chmod 1777 /tmp /var/tmp
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
RUN pi install npm:@harms-haus/pi-cwd \
|
||||
&& pi install npm:pi-web-access \
|
||||
&& pi install npm:context-mode \
|
||||
&& pi install npm:pi-subagents \
|
||||
&& pi install npm:pi-mcp-adapter
|
||||
|
||||
CMD ["pi"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Pi agent provider package."""
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Pi agent provider plugin (PRD 0058, contrib).
|
||||
|
||||
Pi uses ~/.pi/agent/models.json for custom provider/model settings.
|
||||
This provider writes an Ollama-compatible default configuration and
|
||||
lets bottles override the model endpoint and model ids via
|
||||
agent_provider.settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ...agent_provider import (
|
||||
AgentProvider,
|
||||
AgentProviderRuntime,
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
provider_startup_args,
|
||||
)
|
||||
from ...egress import EgressRoute
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...backend import Bottle, BottlePlan
|
||||
|
||||
|
||||
_DEFAULT_BASE_URL = "http://ollama:11434/v1"
|
||||
_DEFAULT_MODEL = "qwen2.5-coder:7b"
|
||||
_DEFAULT_PROVIDER_NAME = "ollama"
|
||||
_DEFAULT_CONTEXT_WINDOW = 4096
|
||||
_DEFAULT_MAX_TOKENS = 1024
|
||||
|
||||
|
||||
def _skills_dir(guest_home: str) -> str:
|
||||
return f"{guest_home}/.pi/agent/skills"
|
||||
|
||||
|
||||
def _prompt_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||
|
||||
|
||||
def _append_system_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.pi/agent/APPEND_SYSTEM.md"
|
||||
|
||||
|
||||
def _models_path(guest_home: str) -> str:
|
||||
return f"{guest_home}/.pi/agent/models.json"
|
||||
|
||||
|
||||
def _runtime_state_repair_script(guest_home: str) -> str:
|
||||
home = shlex.quote(guest_home)
|
||||
pi_home = shlex.quote(f"{guest_home}/.pi")
|
||||
context_sessions = shlex.quote(f"{guest_home}/.pi/context-mode/sessions")
|
||||
return (
|
||||
f"mkdir -p {context_sessions} /tmp/pi-subagents-uid-1000 && "
|
||||
f"chown node:node {home} && "
|
||||
f"chown -R node:node {pi_home} /tmp && "
|
||||
"chmod -R u+rwX /tmp && "
|
||||
f"chmod 755 {home} && "
|
||||
"chown root:root /tmp /var/tmp && "
|
||||
"chmod 1777 /tmp /var/tmp"
|
||||
)
|
||||
|
||||
|
||||
def _settings_value(
|
||||
settings: dict[str, object],
|
||||
key: str,
|
||||
default: object,
|
||||
) -> object:
|
||||
value = settings.get(key)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _settings_int(
|
||||
settings: dict[str, object],
|
||||
key: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
value = _settings_value(settings, key, default)
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, (int, str)):
|
||||
return int(value)
|
||||
return default
|
||||
|
||||
|
||||
def _pi_models_json(
|
||||
settings: dict[str, object],
|
||||
) -> tuple[dict[str, object], str, str, list[str], str]:
|
||||
provider_name = str(
|
||||
_settings_value(settings, "provider", _DEFAULT_PROVIDER_NAME)
|
||||
)
|
||||
base_url = str(_settings_value(settings, "base_url", _DEFAULT_BASE_URL))
|
||||
api = str(_settings_value(settings, "api", "openai-completions"))
|
||||
api_key = settings.get("api_key")
|
||||
api_key_env = str(settings.get("api_key_env", ""))
|
||||
models_raw = _settings_value(settings, "models", [_DEFAULT_MODEL])
|
||||
models = [str(model) for model in models_raw] # type: ignore[union-attr]
|
||||
supports_developer_role = bool(
|
||||
_settings_value(settings, "supports_developer_role", False)
|
||||
)
|
||||
supports_reasoning_effort = bool(
|
||||
_settings_value(settings, "supports_reasoning_effort", False)
|
||||
)
|
||||
max_tokens_field = str(
|
||||
_settings_value(settings, "max_tokens_field", "max_tokens")
|
||||
)
|
||||
context_window = _settings_int(
|
||||
settings, "context_window", _DEFAULT_CONTEXT_WINDOW,
|
||||
)
|
||||
max_tokens = _settings_int(settings, "max_tokens", _DEFAULT_MAX_TOKENS)
|
||||
input_context_window = max(1, context_window - max_tokens)
|
||||
provider: dict[str, object] = {
|
||||
"baseUrl": base_url,
|
||||
"api": api,
|
||||
"compat": {
|
||||
"supportsDeveloperRole": supports_developer_role,
|
||||
"supportsReasoningEffort": supports_reasoning_effort,
|
||||
"maxTokensField": max_tokens_field,
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": model,
|
||||
"name": model,
|
||||
"contextWindow": input_context_window,
|
||||
"maxTokens": max_tokens,
|
||||
}
|
||||
for model in models
|
||||
],
|
||||
}
|
||||
if api_key is not None:
|
||||
provider["apiKey"] = str(api_key)
|
||||
elif api_key_env:
|
||||
provider["apiKey"] = "egress-placeholder"
|
||||
elif provider_name == _DEFAULT_PROVIDER_NAME:
|
||||
provider["apiKey"] = "ollama"
|
||||
payload: dict[str, object] = {
|
||||
"providers": {
|
||||
provider_name: provider,
|
||||
}
|
||||
}
|
||||
return payload, base_url, api_key_env, models, provider_name
|
||||
|
||||
|
||||
def _route_host(base_url: str) -> str:
|
||||
parsed = urlparse(base_url)
|
||||
if not parsed.scheme or not parsed.hostname:
|
||||
die(
|
||||
"agent provider provisioning: pi settings base_url must be an "
|
||||
f"absolute URL (was {base_url!r})"
|
||||
)
|
||||
return parsed.hostname
|
||||
|
||||
|
||||
_RUNTIME = AgentProviderRuntime(
|
||||
template="pi",
|
||||
command="pi",
|
||||
image="bot-bottle-pi:latest",
|
||||
prompt_mode="append_system_prompt",
|
||||
bypass_args=(),
|
||||
resume_args=(),
|
||||
)
|
||||
|
||||
|
||||
class PiAgentProvider(AgentProvider):
|
||||
@property
|
||||
def runtime(self) -> AgentProviderRuntime:
|
||||
return _RUNTIME
|
||||
|
||||
def provision_plan(
|
||||
self,
|
||||
*,
|
||||
dockerfile: str,
|
||||
state_dir: Path,
|
||||
instance_name: str,
|
||||
prompt_file: Path,
|
||||
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 = "",
|
||||
label: str = "",
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
del auth_token, forward_host_credentials, host_env, trusted_project_path
|
||||
del label, color
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
guest_home = self.guest_home
|
||||
settings = dict(provider_settings or {})
|
||||
|
||||
models_payload, base_url, api_key_env, models, provider_name = (
|
||||
_pi_models_json(settings)
|
||||
)
|
||||
extra_startup_args = provider_startup_args(provider_settings)
|
||||
models_file = state_dir / "pi-models.json"
|
||||
models_file.write_text(json.dumps(models_payload, indent=2) + "\n")
|
||||
models_file.chmod(0o600)
|
||||
|
||||
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
||||
auth_scheme = "Bearer" if api_key_env else ""
|
||||
return AgentProvisionPlan(
|
||||
template=_RUNTIME.template,
|
||||
command=_RUNTIME.command,
|
||||
prompt_mode=_RUNTIME.prompt_mode,
|
||||
image=_RUNTIME.image,
|
||||
dockerfile=dockerfile,
|
||||
guest_home=guest_home,
|
||||
instance_name=instance_name,
|
||||
prompt_file=prompt_file,
|
||||
guest_env=resolved_guest_env,
|
||||
has_prompt=has_prompt,
|
||||
startup_args=(
|
||||
"--models",
|
||||
",".join(f"{provider_name}/{model}" for model in models),
|
||||
*extra_startup_args,
|
||||
),
|
||||
dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),),
|
||||
files=(AgentProvisionFile(models_file, _models_path(guest_home)),),
|
||||
egress_routes=(EgressRoute(
|
||||
host=_route_host(base_url),
|
||||
auth_scheme=auth_scheme,
|
||||
token_ref=api_key_env,
|
||||
),),
|
||||
)
|
||||
|
||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.manifest.agent
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||
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 {bottle.name}:{dst}")
|
||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||
|
||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||
prompt_path = _prompt_path(plan.guest_home)
|
||||
append_system_path = _append_system_path(plan.guest_home)
|
||||
bottle.cp_in(str(plan.prompt_file), prompt_path) # type: ignore
|
||||
bottle.exec(
|
||||
f"mkdir -p {shlex.quote(plan.guest_home)}/.pi/agent && "
|
||||
f"cp {shlex.quote(prompt_path)} {shlex.quote(append_system_path)} && "
|
||||
f"chown node:node {shlex.quote(prompt_path)} "
|
||||
f"{shlex.quote(append_system_path)} && "
|
||||
f"chmod 600 {shlex.quote(prompt_path)} "
|
||||
f"{shlex.quote(append_system_path)}",
|
||||
user="root",
|
||||
)
|
||||
# Pi's `--append-system-prompt` takes literal text, not a file path.
|
||||
# Use its documented APPEND_SYSTEM.md discovery path instead.
|
||||
return None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
provision = plan.agent_provision
|
||||
_exec(
|
||||
bottle,
|
||||
_runtime_state_repair_script(plan.guest_home),
|
||||
"could not prepare pi runtime state",
|
||||
)
|
||||
for d in provision.dirs:
|
||||
path = shlex.quote(d.guest_path)
|
||||
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
|
||||
_exec(
|
||||
bottle,
|
||||
f"chown {shlex.quote(d.owner)} {path}",
|
||||
f"could not chown {d.guest_path}",
|
||||
)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chmod {shlex.quote(d.mode)} {path}",
|
||||
f"could not chmod {d.guest_path}",
|
||||
)
|
||||
for f in provision.files:
|
||||
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||
path = shlex.quote(f.guest_path)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chown {shlex.quote(f.owner)} {path}",
|
||||
f"could not chown {f.guest_path}",
|
||||
)
|
||||
_exec(
|
||||
bottle,
|
||||
f"chmod {shlex.quote(f.mode)} {path}",
|
||||
f"could not chmod {f.guest_path}",
|
||||
)
|
||||
|
||||
def provision_supervise_mcp(
|
||||
self,
|
||||
plan: "BottlePlan",
|
||||
bottle: "Bottle",
|
||||
supervise_url: str,
|
||||
) -> None:
|
||||
del plan, bottle, supervise_url
|
||||
|
||||
|
||||
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||
result = bottle.exec(script, user="root")
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
if detail:
|
||||
detail = f": {detail}"
|
||||
die(f"agent provider provisioning: {error}{detail}")
|
||||
@@ -11,6 +11,10 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class DeployKeyCollisionError(RuntimeError):
|
||||
"""Raised when a deploy key title or public key already exists on the repo."""
|
||||
|
||||
|
||||
class DeployKeyProvisioner(ABC):
|
||||
"""Manages a single deploy-key lifecycle on a remote forge."""
|
||||
|
||||
|
||||
+348
-40
@@ -11,8 +11,12 @@ the same try/except import shim pattern.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import re
|
||||
import typing
|
||||
import unicodedata
|
||||
from math import log2
|
||||
from collections import Counter
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
try:
|
||||
@@ -22,7 +26,39 @@ except ImportError: # pragma: no cover - host-side path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token patterns detector (Phase 1a)
|
||||
# Snippet helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SNIPPET_CONTEXT = 40 # chars of surrounding text to include on each side
|
||||
REDACT = "********" # fixed-width replacement for the matched sensitive value
|
||||
|
||||
|
||||
def _snippet(text: str, start: int, end: int) -> str:
|
||||
"""Return context around a match with the matched span replaced by REDACT."""
|
||||
before = text[max(0, start - SNIPPET_CONTEXT):start].replace("\n", " ").replace("\r", " ")
|
||||
after = text[end:end + SNIPPET_CONTEXT].replace("\n", " ").replace("\r", " ")
|
||||
return f"{before}{REDACT}{after}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unicode normalization (defeats confusable-char and combining-mark evasion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
# NFKD separates base characters from combining marks and resolves
|
||||
# compatibility equivalents (fullwidth ASCII, ligatures, etc.)
|
||||
decomposed = unicodedata.normalize("NFKD", text)
|
||||
return "".join(
|
||||
ch for ch in decomposed
|
||||
# Strip combining marks inserted between chars to break patterns
|
||||
if unicodedata.category(ch) != "Mn"
|
||||
# Strip control chars; keep common whitespace (\n \r \t)
|
||||
and (unicodedata.category(ch) != "Cc" or ch in "\n\r\t")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token patterns detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOKEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
@@ -31,60 +67,276 @@ TOKEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("GitHub fine-grained token", re.compile(r"github_pat_[A-Za-z0-9_]{82}")),
|
||||
("Anthropic API key", re.compile(r"sk-ant-[A-Za-z0-9\-_]{93}")),
|
||||
("OpenAI API key", re.compile(r"sk-[A-Za-z0-9]{48}")),
|
||||
("OpenAI project API key", re.compile(r"sk-proj-[A-Za-z0-9_\-]{48,}")),
|
||||
("Stripe live key", re.compile(r"sk_live_[A-Za-z0-9]{24}")),
|
||||
("Generic Bearer JWT", re.compile(r"Bearer\s+[A-Za-z0-9._\-]{50,}")),
|
||||
("HuggingFace token", re.compile(r"hf_[A-Za-z0-9]{34,}")),
|
||||
("Databricks token", re.compile(r"dapi[A-Za-z0-9]{32}")),
|
||||
("Slack token", re.compile(r"xox[baprs]-[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]{24,}")),
|
||||
("npm token", re.compile(r"npm_[A-Za-z0-9]{36}")),
|
||||
("SendGrid API key", re.compile(r"SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}")),
|
||||
("PyPI token", re.compile(r"pypi-[A-Za-z0-9_\-]{80,}")),
|
||||
("HashiCorp Vault token", re.compile(r"hvs\.[A-Za-z0-9_\-]{24,}")),
|
||||
)
|
||||
|
||||
|
||||
def scan_token_patterns(text: str) -> ScanResult | None:
|
||||
def scan_token_patterns(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
) -> ScanResult | None:
|
||||
normalized = _normalize_text(text)
|
||||
for name, pattern in TOKEN_PATTERNS:
|
||||
if pattern.search(text):
|
||||
for m in pattern.finditer(normalized):
|
||||
value = m.group(0)
|
||||
# A value the supervisor has approved (PRD 0062) is no longer a
|
||||
# block — keep scanning so a second, un-approved token in the
|
||||
# same request is still caught.
|
||||
if safe_tokens is not None and value in safe_tokens:
|
||||
continue
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=f"outbound request contains {name}",
|
||||
reason=f"{name} found in {location}",
|
||||
location=location,
|
||||
context=_snippet(normalized, m.start(), m.end()),
|
||||
matched=value,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def redact_tokens(
|
||||
text: str,
|
||||
*,
|
||||
env: typing.Mapping[str, str] | None = None,
|
||||
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||
) -> str:
|
||||
"""Replace token pattern matches and (if env given) provisioned secrets with REDACT."""
|
||||
for _, pattern in TOKEN_PATTERNS:
|
||||
text = pattern.sub(REDACT, text)
|
||||
if env is not None:
|
||||
for key, value in env.items():
|
||||
if any(key.startswith(p) for p in sensitive_prefixes) and value:
|
||||
for variant in _encoded_variants(value):
|
||||
text = text.replace(variant, REDACT)
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known secrets detector (Phase 1b)
|
||||
# Known secrets detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _encoded_variants(secret: str) -> list[str]:
|
||||
"""Return the secret plus base64, URL-encoded, and hex variants."""
|
||||
variants = [secret]
|
||||
"""Return the secret plus common encoded variants for exfil detection."""
|
||||
seen: set[str] = {secret}
|
||||
variants: list[str] = [secret]
|
||||
|
||||
def _add(v: str) -> None:
|
||||
if v not in seen:
|
||||
seen.add(v)
|
||||
variants.append(v)
|
||||
|
||||
secret_bytes = secret.encode("utf-8")
|
||||
|
||||
# Standard base64 — with and without padding
|
||||
b64 = base64.b64encode(secret_bytes).decode("ascii")
|
||||
if b64 != secret:
|
||||
variants.append(b64)
|
||||
url_enc = url_quote(secret, safe="")
|
||||
if url_enc != secret:
|
||||
variants.append(url_enc)
|
||||
hex_enc = secret_bytes.hex()
|
||||
if hex_enc != secret:
|
||||
variants.append(hex_enc)
|
||||
_add(b64)
|
||||
_add(b64.rstrip("="))
|
||||
|
||||
# URL-safe base64 (JWT/OAuth use -_ alphabet) — with and without padding
|
||||
b64url = base64.urlsafe_b64encode(secret_bytes).decode("ascii")
|
||||
_add(b64url)
|
||||
_add(b64url.rstrip("="))
|
||||
|
||||
# URL percent-encoding
|
||||
_add(url_quote(secret, safe=""))
|
||||
|
||||
# Hex — lowercase and uppercase
|
||||
_add(secret_bytes.hex())
|
||||
_add(secret_bytes.hex().upper())
|
||||
|
||||
# Base32 (TOTP seeds, some DNS-exfil channels)
|
||||
_add(base64.b32encode(secret_bytes).decode("ascii"))
|
||||
|
||||
# gzip + base64 (deterministic: mtime=0); recognisable by H4sI prefix
|
||||
_add(base64.b64encode(gzip.compress(secret_bytes, mtime=0)).decode("ascii"))
|
||||
|
||||
return variants
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fragmentation-resistant helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Minimum length of alnum projection for projection-based checks to run.
|
||||
# Short secrets produce too many false positives in projection space.
|
||||
_ALNUM_MIN_LEN = 8
|
||||
|
||||
# Minimum window length for the partial-substring sliding scan.
|
||||
PARTIAL_MATCH_MIN_LEN = 12
|
||||
|
||||
|
||||
def _alnum_projection(text: str) -> str:
|
||||
"""Return text with every non-alphanumeric character stripped.
|
||||
|
||||
Used for fragmentation-resistant matching: separator-injected secrets
|
||||
(spaces, hyphens, dots inserted between characters) are identical to
|
||||
their originals in alnum projection space.
|
||||
"""
|
||||
return "".join(c for c in text if c.isalnum())
|
||||
|
||||
|
||||
def _find_partial_window(secret_alnum: str, text_alnum: str, min_len: int) -> int | None:
|
||||
"""Return the position in text_alnum where any min_len-char window of
|
||||
secret_alnum first appears, or None.
|
||||
|
||||
Slides a window of width min_len across secret_alnum and searches for
|
||||
each window in text_alnum. The first hit position is returned.
|
||||
"""
|
||||
if len(secret_alnum) < min_len or len(text_alnum) < min_len:
|
||||
return None
|
||||
for i in range(len(secret_alnum) - min_len + 1):
|
||||
window = secret_alnum[i:i + min_len]
|
||||
pos = text_alnum.find(window)
|
||||
if pos >= 0:
|
||||
return pos
|
||||
return None
|
||||
|
||||
|
||||
def scan_known_secrets(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
env: typing.Mapping[str, str] | None = None,
|
||||
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
) -> ScanResult | None:
|
||||
if env is None:
|
||||
return None
|
||||
|
||||
# Pre-compute alnum projection of the scan text once; reused per secret.
|
||||
text_alnum: str | None = None
|
||||
|
||||
for key, value in env.items():
|
||||
if not key.startswith("EGRESS_TOKEN_") or not value:
|
||||
if not any(key.startswith(p) for p in sensitive_prefixes) or not value:
|
||||
continue
|
||||
|
||||
# Pass 1: exact match across encoded variants (original behaviour).
|
||||
approved_exact = False
|
||||
for variant in _encoded_variants(value):
|
||||
if variant in text:
|
||||
pos = text.find(variant)
|
||||
if pos >= 0:
|
||||
# The supervisor approves the exact encoded variant found
|
||||
# (PRD 0062); a different encoding of the same secret is a
|
||||
# fresh block.
|
||||
if safe_tokens is not None and variant in safe_tokens:
|
||||
approved_exact = True
|
||||
continue
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"outbound request contains provisioned secret "
|
||||
f"from {key}"
|
||||
),
|
||||
reason=f"provisioned secret from {key} found in {location}",
|
||||
location=location,
|
||||
context=_snippet(text, pos, pos + len(variant)),
|
||||
matched=variant,
|
||||
)
|
||||
if approved_exact:
|
||||
# Exact match was found and approved; projection passes would
|
||||
# fire on the same value, so skip them for this secret.
|
||||
continue
|
||||
|
||||
# Pass 2 & 3: fragmentation-resistant projection checks.
|
||||
secret_alnum = _alnum_projection(value)
|
||||
if len(secret_alnum) < _ALNUM_MIN_LEN:
|
||||
continue
|
||||
|
||||
if text_alnum is None:
|
||||
text_alnum = _alnum_projection(text)
|
||||
|
||||
# Pass 2: full alnum-projection exact match (catches separator injection).
|
||||
pos2 = text_alnum.find(secret_alnum)
|
||||
if pos2 >= 0:
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"provisioned secret from {key} found in {location} "
|
||||
f"(fragmented match — separator injection)"
|
||||
),
|
||||
location=location,
|
||||
context=_snippet(text_alnum, pos2, pos2 + len(secret_alnum)),
|
||||
)
|
||||
|
||||
# Pass 3: sliding-window partial match (catches chunked-substring leaks).
|
||||
pos3 = _find_partial_window(secret_alnum, text_alnum, PARTIAL_MATCH_MIN_LEN)
|
||||
if pos3 is not None:
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"provisioned secret from {key} found in {location} "
|
||||
f"(partial match — at least {PARTIAL_MATCH_MIN_LEN} consecutive "
|
||||
f"alphanumeric chars)"
|
||||
),
|
||||
location=location,
|
||||
context=_snippet(text_alnum, pos3, pos3 + PARTIAL_MATCH_MIN_LEN),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entropy detector (warn-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sliding window size and step for the entropy scan.
|
||||
ENTROPY_WINDOW = 64
|
||||
ENTROPY_STEP = 32
|
||||
|
||||
# Bits-per-character threshold. Random ASCII printable ≈ 6.6 bits; random
|
||||
# lowercase hex ≈ 4 bits; random base64url ≈ 6 bits. 5.5 sits above
|
||||
# typical structured data (JSON, URLs) while staying below truly random
|
||||
# content.
|
||||
ENTROPY_BLOCK_THRESHOLD = 5.5
|
||||
|
||||
|
||||
def _shannon_entropy(text: str) -> float:
|
||||
if not text:
|
||||
return 0.0
|
||||
counts = Counter(text)
|
||||
n = len(text)
|
||||
return -sum((c / n) * log2(c / n) for c in counts.values())
|
||||
|
||||
|
||||
def scan_entropy(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
window: int = ENTROPY_WINDOW,
|
||||
threshold: float = ENTROPY_BLOCK_THRESHOLD,
|
||||
) -> ScanResult | None:
|
||||
"""Warn-only detector: flag windows of `window` chars with Shannon entropy
|
||||
above `threshold` bits per character.
|
||||
|
||||
Never blocks; always returns severity='warn'. Disabled by default —
|
||||
routes must opt in via dlp.outbound_detectors=['entropy'].
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
step = max(1, window // 2)
|
||||
end = len(text)
|
||||
# Scan overlapping windows; also check the final tail if shorter than window.
|
||||
positions = list(range(0, end - window + 1, step))
|
||||
if end < window:
|
||||
positions = [0]
|
||||
elif (end - window) % step != 0:
|
||||
positions.append(end - window)
|
||||
for i in positions:
|
||||
chunk = text[i:i + window]
|
||||
if _shannon_entropy(chunk) >= threshold:
|
||||
return ScanResult(
|
||||
severity="warn",
|
||||
reason=f"high-entropy content in {location} (possible encrypted exfil)",
|
||||
location=location,
|
||||
context=_snippet(text, i, i + len(chunk)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -112,55 +364,111 @@ JAILBREAK_PHRASES: tuple[re.Pattern[str], ...] = (
|
||||
PROXIMITY_CHARS = 500
|
||||
|
||||
|
||||
def _min_distance(
|
||||
def _closest_pair(
|
||||
a_matches: list[re.Match[str]],
|
||||
b_matches: list[re.Match[str]],
|
||||
) -> int | None:
|
||||
"""Smallest char distance between any pair of matches."""
|
||||
if not a_matches or not b_matches:
|
||||
return None
|
||||
best = None
|
||||
) -> tuple[re.Match[str], re.Match[str]] | None:
|
||||
"""Return the pair (a, b) with the smallest character gap, or None."""
|
||||
best: tuple[re.Match[str], re.Match[str]] | None = None
|
||||
best_gap: int | None = None
|
||||
for a in a_matches:
|
||||
for b in b_matches:
|
||||
gap = max(0, max(a.start(), b.start()) - min(a.end(), b.end()))
|
||||
if best is None or gap < best:
|
||||
best = gap
|
||||
if best_gap is None or gap < best_gap:
|
||||
best_gap = gap
|
||||
best = (a, b)
|
||||
return best
|
||||
|
||||
|
||||
def scan_naive_injection(text: str) -> ScanResult | None:
|
||||
location = "response body"
|
||||
disclosure_hits = [m for p in DISCLOSURE_PHRASES for m in p.finditer(text)]
|
||||
jailbreak_hits = [m for p in JAILBREAK_PHRASES for m in p.finditer(text)]
|
||||
|
||||
if disclosure_hits and jailbreak_hits:
|
||||
dist = _min_distance(disclosure_hits, jailbreak_hits)
|
||||
if dist is not None and dist <= PROXIMITY_CHARS:
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"disclosure and jailbreak phrases within "
|
||||
f"{dist} chars in response"
|
||||
),
|
||||
)
|
||||
pair = _closest_pair(disclosure_hits, jailbreak_hits)
|
||||
if pair is not None:
|
||||
dist = max(0, max(pair[0].start(), pair[1].start()) - min(pair[0].end(), pair[1].end()))
|
||||
if dist <= PROXIMITY_CHARS:
|
||||
first = pair[0] if pair[0].start() <= pair[1].start() else pair[1]
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"disclosure and jailbreak phrases within "
|
||||
f"{dist} chars in {location}"
|
||||
),
|
||||
location=location,
|
||||
context=_snippet(text, first.start(), first.end()),
|
||||
)
|
||||
|
||||
if disclosure_hits:
|
||||
m = disclosure_hits[0]
|
||||
return ScanResult(
|
||||
severity="warn",
|
||||
reason="prompt disclosure phrase detected in response",
|
||||
reason=f"prompt disclosure phrase detected in {location}",
|
||||
location=location,
|
||||
context=_snippet(text, m.start(), m.end()),
|
||||
)
|
||||
|
||||
if jailbreak_hits:
|
||||
m = jailbreak_hits[0]
|
||||
return ScanResult(
|
||||
severity="warn",
|
||||
reason="jailbreak phrase detected in response",
|
||||
reason=f"jailbreak phrase detected in {location}",
|
||||
location=location,
|
||||
context=_snippet(text, m.start(), m.end()),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRLF injection detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# URL-encoded CRLF is never legitimate in a request URL or header value.
|
||||
_CRLF_ENCODED_RE = re.compile(r"%0[dD]%0[aA]", re.ASCII)
|
||||
# Literal CRLF followed by a header-name pattern indicates header injection.
|
||||
_CRLF_HEADER_INJECT_RE = re.compile(r"\r\n[A-Za-z][A-Za-z0-9\-]+\s*:", re.ASCII)
|
||||
|
||||
|
||||
def strip_crlf(text: str) -> str:
|
||||
"""Remove URL-encoded and literal CRLF injection sequences from a request
|
||||
surface (PRD 0062 redact policy). Used to scrub the request line / headers
|
||||
so the request can be forwarded instead of hard-blocked."""
|
||||
text = _CRLF_ENCODED_RE.sub("", text)
|
||||
return _CRLF_HEADER_INJECT_RE.sub(lambda m: m.group(0)[2:], text)
|
||||
|
||||
|
||||
def scan_crlf_injection(text: str) -> ScanResult | None:
|
||||
if _CRLF_ENCODED_RE.search(text):
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason="URL-encoded CRLF (%0d%0a) in outbound request",
|
||||
)
|
||||
if _CRLF_HEADER_INJECT_RE.search(text):
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason="CRLF header injection pattern in outbound request",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ENTROPY_BLOCK_THRESHOLD",
|
||||
"ENTROPY_WINDOW",
|
||||
"ENTROPY_STEP",
|
||||
"PARTIAL_MATCH_MIN_LEN",
|
||||
"REDACT",
|
||||
"SNIPPET_CONTEXT",
|
||||
"TOKEN_PATTERNS",
|
||||
"_alnum_projection",
|
||||
"_shannon_entropy",
|
||||
"redact_tokens",
|
||||
"scan_crlf_injection",
|
||||
"scan_entropy",
|
||||
"scan_known_secrets",
|
||||
"scan_naive_injection",
|
||||
"scan_token_patterns",
|
||||
"strip_crlf",
|
||||
]
|
||||
|
||||
+141
-42
@@ -10,12 +10,14 @@ specific and lives on concrete subclasses (see
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import secrets
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .egress_addon_core import (
|
||||
ON_MATCH_REDACT,
|
||||
HeaderMatch as CoreHeaderMatch,
|
||||
MatchEntry as CoreMatchEntry,
|
||||
PathMatch as CorePathMatch,
|
||||
@@ -24,13 +26,58 @@ from .egress_addon_core import (
|
||||
from .log import die
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import Bottle
|
||||
from .manifest import ManifestBottle
|
||||
|
||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||
|
||||
EGRESS_HOSTNAME = "egress"
|
||||
|
||||
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
||||
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
|
||||
|
||||
_CANARY_ENV_WORDS = (
|
||||
"ACCORD",
|
||||
"ANCHOR",
|
||||
"ATLAS",
|
||||
"CANON",
|
||||
"CIPHER",
|
||||
"EMBER",
|
||||
"FALCON",
|
||||
"HARBOR",
|
||||
"LANTERN",
|
||||
"MARBLE",
|
||||
"NOVA",
|
||||
"ORBIT",
|
||||
"PIVOT",
|
||||
"RADIUS",
|
||||
"SUMMIT",
|
||||
"VECTOR",
|
||||
)
|
||||
|
||||
|
||||
def _random_canary_env() -> str:
|
||||
first = secrets.choice(_CANARY_ENV_WORDS)
|
||||
remaining = tuple(word for word in _CANARY_ENV_WORDS if word != first)
|
||||
second = secrets.choice(remaining)
|
||||
return f"{first}_{second}_SECRET"
|
||||
|
||||
|
||||
def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||
"""Return sidecar env entries needed by egress across all backends."""
|
||||
env: list[str] = []
|
||||
if plan.routes:
|
||||
env.extend(sorted(plan.token_env_map.keys()))
|
||||
if plan.canary and plan.canary_env:
|
||||
env.append(f"{plan.canary_env}={plan.canary}")
|
||||
env.append(f"BOT_BOTTLE_SENSITIVE_PREFIXES={plan.canary_env}")
|
||||
return tuple(env)
|
||||
|
||||
|
||||
def egress_agent_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||
"""Return agent-visible egress env entries shared by all backends."""
|
||||
if plan.canary and plan.canary_env:
|
||||
return (f"{plan.canary_env}={plan.canary}",)
|
||||
return ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -62,10 +109,13 @@ class EgressPlan:
|
||||
egress_network: str = ""
|
||||
mitmproxy_ca_host_path: Path = Path()
|
||||
mitmproxy_ca_cert_only_host_path: Path = Path()
|
||||
log: int = 0
|
||||
canary: str = ""
|
||||
canary_env: str = ""
|
||||
|
||||
|
||||
def egress_manifest_routes(
|
||||
bottle: Bottle,
|
||||
bottle: ManifestBottle,
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
out: list[EgressRoute] = []
|
||||
for r in bottle.egress.routes:
|
||||
@@ -90,24 +140,41 @@ def egress_manifest_routes(
|
||||
auth_scheme=r.AuthScheme,
|
||||
token_ref=r.TokenRef,
|
||||
roles=r.Role,
|
||||
git_fetch=r.GitFetch,
|
||||
outbound_detectors=r.OutboundDetectors,
|
||||
inbound_detectors=r.InboundDetectors,
|
||||
outbound_on_match=r.OutboundOnMatch,
|
||||
))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def egress_routes_for_bottle(
|
||||
bottle: Bottle,
|
||||
bottle: ManifestBottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
manifest = egress_manifest_routes(bottle)
|
||||
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
|
||||
merged = list(provider_routes) + [
|
||||
merged = list(_default_provider_on_match(provider_routes)) + [
|
||||
r for r in manifest if r.host.lower() not in provisioned_hosts
|
||||
]
|
||||
return _assign_token_slots(merged)
|
||||
|
||||
|
||||
def _default_provider_on_match(
|
||||
provider_routes: tuple[EgressRoute, ...],
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
"""Provider routes (the agent talking to its own LLM API) default to the
|
||||
`redact` on-match policy (PRD 0062): high-volume conversation payloads are
|
||||
the worst source of token-shaped false positives, so a match is scrubbed
|
||||
and forwarded rather than hard-blocked or queued for the operator. A
|
||||
provider that sets `outbound_on_match` explicitly keeps its choice."""
|
||||
return tuple(
|
||||
r if r.outbound_on_match
|
||||
else dataclasses.replace(r, outbound_on_match=ON_MATCH_REDACT)
|
||||
for r in provider_routes
|
||||
)
|
||||
|
||||
|
||||
def _assign_token_slots(
|
||||
routes: list[EgressRoute],
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
@@ -172,7 +239,13 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||
entry_data["headers"] = headers_data
|
||||
matches_data.append(entry_data)
|
||||
fields["matches"] = matches_data
|
||||
if r.outbound_detectors is not None or r.inbound_detectors is not None:
|
||||
if r.git_fetch:
|
||||
fields["git"] = {"fetch": True}
|
||||
if (
|
||||
r.outbound_detectors is not None
|
||||
or r.inbound_detectors is not None
|
||||
or r.outbound_on_match
|
||||
):
|
||||
dlp: dict[str, object] = {}
|
||||
if r.outbound_detectors is not None:
|
||||
dlp["outbound_detectors"] = (
|
||||
@@ -184,16 +257,54 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||
False if not r.inbound_detectors
|
||||
else list(r.inbound_detectors)
|
||||
)
|
||||
if r.outbound_on_match:
|
||||
dlp["outbound_on_match"] = r.outbound_on_match
|
||||
fields["dlp"] = dlp
|
||||
return fields
|
||||
|
||||
|
||||
def _render_match_entry(entry: dict[str, object]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
first_key = True
|
||||
if "paths" in entry:
|
||||
lines.append(" - paths:")
|
||||
first_key = False
|
||||
for pd in entry["paths"]: # type: ignore[union-attr]
|
||||
pd_dict: dict[str, str] = pd # type: ignore[assignment]
|
||||
if "type" in pd_dict:
|
||||
lines.append(f' - type: "{pd_dict["type"]}"')
|
||||
lines.append(f' value: "{pd_dict["value"]}"')
|
||||
else:
|
||||
lines.append(f' - value: "{pd_dict["value"]}"')
|
||||
if "methods" in entry:
|
||||
methods_str = ", ".join(f'"{m}"' for m in entry["methods"]) # type: ignore[union-attr]
|
||||
prefix = " - " if first_key else " "
|
||||
lines.append(f'{prefix}methods: [{methods_str}]')
|
||||
first_key = False
|
||||
if "headers" in entry:
|
||||
prefix = " - " if first_key else " "
|
||||
lines.append(f"{prefix}headers:")
|
||||
first_key = False
|
||||
for hd in entry["headers"]: # type: ignore[union-attr]
|
||||
hd_dict: dict[str, str] = hd # type: ignore[assignment]
|
||||
lines.append(f' - name: "{hd_dict["name"]}"')
|
||||
lines.append(f' value: "{hd_dict["value"]}"')
|
||||
if first_key:
|
||||
lines.append(" - {}")
|
||||
return lines
|
||||
|
||||
|
||||
def egress_render_routes(
|
||||
routes: tuple[EgressRoute, ...],
|
||||
*,
|
||||
log: int = 0,
|
||||
) -> str:
|
||||
lines: list[str] = ["routes:"]
|
||||
lines: list[str] = []
|
||||
if log:
|
||||
lines.append(f"log: {log}")
|
||||
lines.append("routes:")
|
||||
if not routes:
|
||||
lines[0] = "routes: []"
|
||||
lines[-1] = "routes: []"
|
||||
return "\n".join(lines) + "\n"
|
||||
for r in routes:
|
||||
f = _route_to_yaml_fields(r)
|
||||
@@ -203,38 +314,13 @@ def egress_render_routes(
|
||||
lines.append(f' token_env: "{f["token_env"]}"')
|
||||
if "matches" in f:
|
||||
lines.append(" matches:")
|
||||
for entry in f["matches"]: # type: ignore
|
||||
entry_dict: dict[str, object] = entry # type: ignore
|
||||
first_key = True
|
||||
if "paths" in entry_dict:
|
||||
lines.append(" - paths:")
|
||||
first_key = False
|
||||
for pd in entry_dict["paths"]: # type: ignore
|
||||
pd_dict: dict[str, str] = pd # type: ignore
|
||||
if "type" in pd_dict:
|
||||
lines.append(f' - type: "{pd_dict["type"]}"')
|
||||
lines.append(f' value: "{pd_dict["value"]}"')
|
||||
else:
|
||||
lines.append(f' - value: "{pd_dict["value"]}"')
|
||||
if "methods" in entry_dict:
|
||||
methods_str = ", ".join(
|
||||
f'"{m}"' for m in entry_dict["methods"] # type: ignore
|
||||
)
|
||||
prefix = " - " if first_key else " "
|
||||
lines.append(f'{prefix}methods: [{methods_str}]')
|
||||
first_key = False
|
||||
if "headers" in entry_dict:
|
||||
prefix = " - " if first_key else " "
|
||||
lines.append(f"{prefix}headers:")
|
||||
first_key = False
|
||||
for hd in entry_dict["headers"]: # type: ignore
|
||||
hd_dict: dict[str, str] = hd # type: ignore
|
||||
lines.append(f' - name: "{hd_dict["name"]}"')
|
||||
lines.append(f' value: "{hd_dict["value"]}"')
|
||||
if "type" in hd_dict:
|
||||
lines.append(f' type: "{hd_dict["type"]}"')
|
||||
if first_key:
|
||||
lines.append(" - {}")
|
||||
for entry in f["matches"]: # type: ignore[union-attr]
|
||||
lines.extend(_render_match_entry(entry)) # type: ignore[arg-type]
|
||||
if "git" in f:
|
||||
git_dict: dict[str, object] = f["git"] # type: ignore
|
||||
lines.append(" git:")
|
||||
if git_dict.get("fetch") is True:
|
||||
lines.append(" fetch: true")
|
||||
if "dlp" in f:
|
||||
dlp_dict: dict[str, object] = f["dlp"] # type: ignore
|
||||
lines.append(" dlp:")
|
||||
@@ -244,6 +330,8 @@ def egress_render_routes(
|
||||
elif isinstance(dv, list):
|
||||
items_str = ", ".join(f'"{x}"' for x in dv)
|
||||
lines.append(f" {dk}: [{items_str}]")
|
||||
elif isinstance(dv, str):
|
||||
lines.append(f' {dk}: "{dv}"')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
@@ -273,25 +361,34 @@ def egress_resolve_token_values(
|
||||
class Egress(ABC):
|
||||
def prepare(
|
||||
self,
|
||||
bottle: Bottle,
|
||||
bottle: ManifestBottle,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> EgressPlan:
|
||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||
routes_path = stage_dir / "egress_routes.yaml"
|
||||
routes_path.write_text(egress_render_routes(routes))
|
||||
log = bottle.egress.Log
|
||||
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||
routes_path.chmod(0o600)
|
||||
# Generate a per-session fake secret under a plausible random env name.
|
||||
# The sidecar marks that exact env name as sensitive for known-secret
|
||||
# scanning; the agent receives the same name/value as exfil bait.
|
||||
canary = secrets.token_urlsafe(32)
|
||||
return EgressPlan(
|
||||
slug=slug,
|
||||
routes_path=routes_path,
|
||||
routes=routes,
|
||||
token_env_map=egress_token_env_map(routes),
|
||||
log=log,
|
||||
canary=canary,
|
||||
canary_env=_random_canary_env(),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||
"EGRESS_HOSTNAME",
|
||||
"EGRESS_ROUTES_FILENAME",
|
||||
"EGRESS_ROUTES_IN_CONTAINER",
|
||||
"Egress",
|
||||
"EgressPlan",
|
||||
@@ -300,5 +397,7 @@ __all__ = [
|
||||
"egress_render_routes",
|
||||
"egress_resolve_token_values",
|
||||
"egress_routes_for_bottle",
|
||||
"egress_agent_env_entries",
|
||||
"egress_sidecar_env_entries",
|
||||
"egress_token_env_map",
|
||||
]
|
||||
|
||||
+421
-36
@@ -5,54 +5,107 @@ egress container."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from mitmproxy import http # type: ignore[import-not-found]
|
||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||
|
||||
from egress_addon_core import ( # type: ignore[import-not-found]
|
||||
from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
Config,
|
||||
Route,
|
||||
ScanResult,
|
||||
build_inbound_scan_text,
|
||||
build_outbound_scan_text,
|
||||
build_token_allow_payload,
|
||||
decide,
|
||||
decide_git_fetch,
|
||||
is_git_fetch_request,
|
||||
is_git_push_request,
|
||||
load_routes,
|
||||
load_config,
|
||||
match_route,
|
||||
outbound_scan_headers,
|
||||
route_to_yaml_dict,
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
|
||||
try:
|
||||
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
|
||||
redact_tokens,
|
||||
strip_crlf,
|
||||
)
|
||||
|
||||
try:
|
||||
import supervise as _sv # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
||||
|
||||
|
||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
||||
|
||||
INTROSPECT_HOST = "_egress.local"
|
||||
|
||||
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
||||
# operator's supervisor decision (PRD 0062), overridable via env.
|
||||
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
|
||||
# Filesystem poll cadence while awaiting the operator's response.
|
||||
TOKEN_ALLOW_POLL_INTERVAL_SECONDS = 0.5
|
||||
|
||||
# Fixed operator guidance attached to every token-allow proposal.
|
||||
_TOKEN_ALLOW_JUSTIFICATION = (
|
||||
"egress DLP blocked an outbound request carrying a detected token. "
|
||||
"Approve only if this value is a false positive or a credential this "
|
||||
"request legitimately needs; the value is then allowed for the life of "
|
||||
"this bottle's egress proxy."
|
||||
)
|
||||
|
||||
|
||||
class EgressAddon:
|
||||
def __init__(self) -> None:
|
||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
||||
self.routes: tuple[Route, ...] = ()
|
||||
self.config: Config = Config(routes=())
|
||||
# Tokens the operator has approved this session (PRD 0062). In-memory
|
||||
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||
# runs the addon hooks, so no lock is needed.
|
||||
self.safe_tokens: set[str] = set()
|
||||
self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip()
|
||||
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||
self._reload(initial=True)
|
||||
self._install_sighup()
|
||||
|
||||
def _supervise_available(self) -> bool:
|
||||
return bool(self._supervise_queue_dir and self._supervise_slug)
|
||||
|
||||
def _reload(self, *, initial: bool = False) -> None:
|
||||
try:
|
||||
text = Path(self.routes_path).read_text(encoding="utf-8")
|
||||
new_routes = load_routes(text)
|
||||
new_config = load_config(text)
|
||||
except (OSError, ValueError) as e:
|
||||
tag = "boot" if initial else "SIGHUP"
|
||||
sys.stderr.write(
|
||||
f"egress: {tag} load failed: {e}\n"
|
||||
)
|
||||
if initial:
|
||||
self.routes = ()
|
||||
self.config = Config(routes=())
|
||||
return
|
||||
self.routes = new_routes
|
||||
self.config = new_config
|
||||
log_label = ("off", "blocks", "full")[self.config.log]
|
||||
sys.stderr.write(
|
||||
f"egress: loaded {len(self.routes)} route(s): "
|
||||
f"{', '.join(r.host for r in self.routes)}\n"
|
||||
f"egress: loaded {len(self.config.routes)} route(s): "
|
||||
f"{', '.join(r.host for r in self.config.routes)}"
|
||||
f" [log={log_label}]\n"
|
||||
)
|
||||
|
||||
def _install_sighup(self) -> None:
|
||||
@@ -68,7 +121,7 @@ class EgressAddon:
|
||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
||||
if path == "/allowlist":
|
||||
payload = json.dumps(
|
||||
{"routes": [dataclasses.asdict(r) for r in self.routes]},
|
||||
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
|
||||
indent=2,
|
||||
).encode("utf-8")
|
||||
flow.response = http.Response.make(
|
||||
@@ -82,15 +135,67 @@ class EgressAddon:
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
|
||||
def _block(self, flow: http.HTTPFlow, reason: str) -> None:
|
||||
sys.stderr.write(f"{reason}\n")
|
||||
def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]:
|
||||
return {
|
||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
||||
"method": flow.request.method,
|
||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
||||
}
|
||||
|
||||
def _block(
|
||||
self,
|
||||
flow: http.HTTPFlow,
|
||||
reason: str,
|
||||
ctx: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
if self.config.log >= LOG_BLOCKS:
|
||||
entry: dict[str, object] = {"event": "egress_block", "reason": reason}
|
||||
if ctx:
|
||||
entry.update(ctx)
|
||||
sys.stderr.write(json.dumps(entry) + "\n")
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
reason.encode("utf-8"),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
|
||||
def request(self, flow: http.HTTPFlow) -> None:
|
||||
def _log_request(self, flow: http.HTTPFlow) -> None:
|
||||
headers = {
|
||||
k: redact_tokens(v, env=os.environ)
|
||||
for k, v in flow.request.headers.items()
|
||||
if k.lower() != "authorization"
|
||||
}
|
||||
body = redact_tokens(flow.request.get_text(strict=False) or "", env=os.environ)
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_request",
|
||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
||||
"method": flow.request.method,
|
||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def _log_response(self, flow: http.HTTPFlow) -> None:
|
||||
headers = {
|
||||
k: redact_tokens(v, env=os.environ)
|
||||
for k, v in flow.response.headers.items()
|
||||
}
|
||||
body = redact_tokens(flow.response.get_text(strict=False) or "", env=os.environ)
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_response",
|
||||
"host": flow.request.pretty_host,
|
||||
"status": flow.response.status_code,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
async def request(self, flow: http.HTTPFlow) -> None:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
if flow.request.pretty_host == INTROSPECT_HOST:
|
||||
@@ -98,21 +203,15 @@ class EgressAddon:
|
||||
return
|
||||
|
||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
||||
# agent tried to smuggle in the Authorization header.
|
||||
route = match_route(self.routes, flow.request.pretty_host)
|
||||
# agent tried to smuggle in any header, path, query param, or body.
|
||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||
if route is not None:
|
||||
body = flow.request.get_text(strict=False) or ""
|
||||
auth_header = flow.request.headers.get("authorization", "")
|
||||
scan_text = body
|
||||
if auth_header:
|
||||
scan_text = auth_header + "\n" + body
|
||||
dlp_result = scan_outbound(route, scan_text, os.environ)
|
||||
if dlp_result is not None and dlp_result.severity == "block":
|
||||
self._block(flow, f"egress DLP: {dlp_result.reason}")
|
||||
if not await self._handle_outbound_dlp(flow, route):
|
||||
return
|
||||
|
||||
# Strip inbound Authorization — agent cannot smuggle tokens.
|
||||
flow.request.headers.pop("authorization", None)
|
||||
# The redact policy may have rewritten the request line; recompute
|
||||
# the path/query the git checks below rely on.
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
if is_git_push_request(request_path, query):
|
||||
self._block(
|
||||
@@ -120,14 +219,31 @@ class EgressAddon:
|
||||
"egress: git push over HTTPS is not supported; "
|
||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
||||
"git-gate's pre-receive hook).",
|
||||
ctx=self._req_ctx(flow),
|
||||
)
|
||||
return
|
||||
|
||||
if is_git_fetch_request(request_path, query):
|
||||
git_decision = decide_git_fetch(
|
||||
self.config.routes, flow.request.pretty_host,
|
||||
)
|
||||
if git_decision.action == "block":
|
||||
self._block(
|
||||
flow,
|
||||
git_decision.reason,
|
||||
ctx=self._req_ctx(flow),
|
||||
)
|
||||
return
|
||||
|
||||
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
||||
# are caught above; the route may inject sidecar-owned auth below.
|
||||
flow.request.headers.pop("authorization", None)
|
||||
|
||||
# Build headers mapping for match evaluation
|
||||
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
||||
|
||||
decision = decide(
|
||||
self.routes,
|
||||
self.config.routes,
|
||||
flow.request.pretty_host,
|
||||
request_path,
|
||||
os.environ,
|
||||
@@ -136,29 +252,298 @@ class EgressAddon:
|
||||
)
|
||||
|
||||
if decision.action == "block":
|
||||
self._block(flow, decision.reason)
|
||||
self._block(flow, decision.reason, ctx=self._req_ctx(flow))
|
||||
return
|
||||
|
||||
if decision.inject_authorization is not None:
|
||||
flow.request.headers["authorization"] = decision.inject_authorization
|
||||
|
||||
if self.config.log >= LOG_FULL:
|
||||
self._log_request(flow)
|
||||
|
||||
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||
ctx = self._req_ctx(flow)
|
||||
if result.context:
|
||||
ctx = {**ctx, "context": result.context}
|
||||
self._block(flow, f"egress DLP: {result.reason}", ctx=ctx)
|
||||
|
||||
async def _handle_outbound_dlp(
|
||||
self,
|
||||
flow: http.HTTPFlow,
|
||||
route: Route,
|
||||
) -> bool:
|
||||
"""Scan the outbound request and apply the route's on-match policy
|
||||
(PRD 0062). Returns True if the request may be forwarded, False if a
|
||||
403 response has been written to `flow`.
|
||||
|
||||
Loops so the supervise policy can re-scan after each approval — a
|
||||
second, un-approved token in the same request is still caught."""
|
||||
while True:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
body = flow.request.get_text(strict=False) or ""
|
||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, body,
|
||||
)
|
||||
# CRLF is scanned only over the request line + headers, never the
|
||||
# body (see scan_outbound) — a body is not an injection vector.
|
||||
crlf_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
result = scan_outbound(
|
||||
route, scan_text, os.environ,
|
||||
safe_tokens=self.safe_tokens, crlf_text=crlf_text,
|
||||
)
|
||||
if result is None or result.severity != "block":
|
||||
return True
|
||||
|
||||
policy = route.outbound_on_match or DEFAULT_OUTBOUND_ON_MATCH
|
||||
|
||||
# redact scrubs every detection (tokens and structural CRLF) and
|
||||
# forwards; it fails closed only if a match survives the scrub.
|
||||
if policy == ON_MATCH_REDACT:
|
||||
if self._redact_outbound(flow, route):
|
||||
if self.config.log >= LOG_BLOCKS:
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_redacted",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
return True
|
||||
self._block(
|
||||
flow,
|
||||
f"egress DLP: {result.reason}; redaction could not remove "
|
||||
"all matches (e.g. a match in the hostname)",
|
||||
ctx=self._req_ctx(flow),
|
||||
)
|
||||
return False
|
||||
|
||||
# Structural blocks (CRLF, no safelist-able value) cannot be
|
||||
# supervised — there is nothing to approve and remember — so under
|
||||
# block/supervise they are a hard 403.
|
||||
if policy == ON_MATCH_BLOCK or not result.matched:
|
||||
self._block_dlp(flow, result)
|
||||
return False
|
||||
|
||||
# supervise (default): hold the request for operator approval.
|
||||
# Fall back to a hard 403 when supervise isn't wired for the bottle.
|
||||
if not self._supervise_available():
|
||||
self._block_dlp(flow, result)
|
||||
return False
|
||||
approved = await self._supervise_token_block(flow, request_path, result)
|
||||
if not approved:
|
||||
return False # _supervise_token_block wrote the 403 response
|
||||
# loop: the approved value is now in safe_tokens; re-scan.
|
||||
|
||||
def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
|
||||
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
||||
request surfaces (body, headers, path/query) and re-scan. Returns True
|
||||
if the request is now clean; False if a block-severity match remains on
|
||||
a surface redaction cannot rewrite (the hostname) so the caller fails
|
||||
closed."""
|
||||
body = flow.request.get_text(strict=False)
|
||||
if body:
|
||||
redacted_body = redact_tokens(body, env=os.environ)
|
||||
if redacted_body != body:
|
||||
flow.request.text = redacted_body
|
||||
for name, value in list(flow.request.headers.items()):
|
||||
if name.lower() == "host":
|
||||
continue # routing-critical; never a legitimate token
|
||||
redacted = strip_crlf(redact_tokens(value, env=os.environ))
|
||||
if redacted != value:
|
||||
flow.request.headers[name] = redacted
|
||||
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
|
||||
if redacted_path != flow.request.path:
|
||||
flow.request.path = redacted_path
|
||||
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
new_body = flow.request.get_text(strict=False) or ""
|
||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, new_body,
|
||||
)
|
||||
crlf_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
|
||||
return result is None or result.severity != "block"
|
||||
|
||||
async def _supervise_token_block(
|
||||
self,
|
||||
flow: http.HTTPFlow,
|
||||
request_path: str,
|
||||
result: ScanResult,
|
||||
) -> bool:
|
||||
"""Route a token DLP block to the operator's supervisor queue and wait.
|
||||
|
||||
Returns True if the operator approved (the matched value is added to
|
||||
`self.safe_tokens` and the caller re-scans); False if the request must
|
||||
be blocked (a 403 response has been written to `flow`)."""
|
||||
host = flow.request.pretty_host
|
||||
payload = build_token_allow_payload(
|
||||
redact_tokens(host, env=os.environ),
|
||||
flow.request.method,
|
||||
redact_tokens(request_path, env=os.environ),
|
||||
result,
|
||||
)
|
||||
proposal = _sv.Proposal.new(
|
||||
bottle_slug=self._supervise_slug,
|
||||
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
|
||||
proposed_file=payload,
|
||||
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||
current_file_hash=_sv.sha256_hex(payload),
|
||||
)
|
||||
queue_dir = Path(self._supervise_queue_dir)
|
||||
try:
|
||||
_sv.write_proposal(queue_dir, proposal)
|
||||
except OSError as e:
|
||||
sys.stderr.write(
|
||||
f"egress: could not queue token-allow proposal: {e}; "
|
||||
"blocking request\n"
|
||||
)
|
||||
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
|
||||
return False
|
||||
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_token_supervise",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
"proposal": proposal.id,
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
|
||||
response = await self._await_token_response(queue_dir, proposal.id)
|
||||
_sv.archive_proposal(queue_dir, proposal.id)
|
||||
|
||||
if response is not None and response.status in (
|
||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||
):
|
||||
self.safe_tokens.add(result.matched)
|
||||
if self.config.log >= LOG_BLOCKS:
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_token_allowed",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
"proposal": proposal.id,
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
return True
|
||||
|
||||
if response is None:
|
||||
reason = (
|
||||
f"egress DLP: {result.reason}; supervisor approval timed out "
|
||||
f"after {self._token_allow_timeout:g}s"
|
||||
)
|
||||
else:
|
||||
reason = f"egress DLP: {result.reason}; supervisor rejected the request"
|
||||
self._block(flow, reason, ctx=self._req_ctx(flow))
|
||||
return False
|
||||
|
||||
async def _await_token_response(
|
||||
self,
|
||||
queue_dir: Path,
|
||||
proposal_id: str,
|
||||
) -> "_sv.Response | None":
|
||||
"""Poll the queue dir for the operator's response without blocking the
|
||||
proxy event loop. Returns the Response, or None on timeout."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self._token_allow_timeout
|
||||
while True:
|
||||
try:
|
||||
return _sv.read_response(queue_dir, proposal_id)
|
||||
except (OSError, ValueError, KeyError):
|
||||
# Not written yet, or a partial/malformed write — retry until
|
||||
# the deadline, then fail closed.
|
||||
pass
|
||||
if loop.time() >= deadline:
|
||||
return None
|
||||
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
||||
|
||||
def response(self, flow: http.HTTPFlow) -> None:
|
||||
"""DLP inbound scan on response bodies (PRD 0053)."""
|
||||
route = match_route(self.routes, flow.request.pretty_host)
|
||||
"""DLP inbound scan on response headers and body."""
|
||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||
if route is None:
|
||||
return
|
||||
if flow.response is None:
|
||||
return
|
||||
if self.config.log >= LOG_FULL:
|
||||
self._log_response(flow)
|
||||
resp_headers = {k.lower(): v for k, v in flow.response.headers.items()}
|
||||
body = flow.response.get_text(strict=False) or ""
|
||||
if not body:
|
||||
scan_text = build_inbound_scan_text(resp_headers, body)
|
||||
if not scan_text:
|
||||
return
|
||||
result = scan_inbound(route, body)
|
||||
result = scan_inbound(route, scan_text)
|
||||
if result is None:
|
||||
return
|
||||
resp_ctx: dict[str, object] = {
|
||||
**self._req_ctx(flow),
|
||||
"response_status": flow.response.status_code,
|
||||
}
|
||||
if result.context:
|
||||
resp_ctx = {**resp_ctx, "context": result.context}
|
||||
if result.severity == "block":
|
||||
self._block(flow, f"egress DLP: {result.reason}")
|
||||
elif result.severity == "warn":
|
||||
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
||||
self._block(flow, f"egress DLP: {result.reason}", ctx=resp_ctx)
|
||||
elif result.severity == "warn" and self.config.log >= LOG_BLOCKS:
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_warn",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
**resp_ctx,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
||||
"""DLP scan on WebSocket frames.
|
||||
|
||||
Outbound frames (from_client) are scanned for credential leakage;
|
||||
inbound frames are scanned for prompt injection. On a block the
|
||||
entire connection is killed — there is no HTTP response surface to
|
||||
write to after the upgrade.
|
||||
"""
|
||||
if flow.websocket is None: # type: ignore[union-attr]
|
||||
return
|
||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||
if route is None:
|
||||
return
|
||||
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
||||
content = message.content.decode("utf-8", errors="replace")
|
||||
if message.from_client:
|
||||
# A WebSocket data frame is not an HTTP request line, so CRLF is
|
||||
# not an injection vector here — scan only for credential leakage.
|
||||
result = scan_outbound(
|
||||
route, content, os.environ,
|
||||
safe_tokens=self.safe_tokens, crlf_text="",
|
||||
)
|
||||
if result is not None and result.severity == "block":
|
||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
||||
flow.kill() # type: ignore[union-attr]
|
||||
else:
|
||||
result = scan_inbound(route, content)
|
||||
if result is not None:
|
||||
if result.severity == "block":
|
||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
||||
flow.kill() # type: ignore[union-attr]
|
||||
elif result.severity == "warn":
|
||||
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
||||
|
||||
|
||||
def _token_allow_timeout_from_env(env: "os._Environ[str]") -> float:
|
||||
"""Read EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS; fall back to the default on an
|
||||
unset or invalid value (a bad value should not wedge egress at boot)."""
|
||||
raw = env.get("EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS", "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
value = 0.0
|
||||
if value <= 0:
|
||||
sys.stderr.write(
|
||||
"egress: invalid EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS="
|
||||
f"{raw!r}; using default {DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS:g}s\n"
|
||||
)
|
||||
return DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS
|
||||
return value
|
||||
|
||||
|
||||
addons = [EgressAddon()]
|
||||
|
||||
+329
-19
@@ -34,9 +34,18 @@ VALID_METHODS = frozenset({
|
||||
"CONNECT",
|
||||
})
|
||||
|
||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets"})
|
||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets", "entropy"})
|
||||
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
||||
|
||||
# Per-route policy for what the proxy does when an outbound DLP detector
|
||||
# matches a token (PRD 0062).
|
||||
ON_MATCH_BLOCK = "block" # hard 403, never overridable
|
||||
ON_MATCH_REDACT = "redact" # scrub the matched value, forward the request
|
||||
ON_MATCH_SUPERVISE = "supervise" # queue for operator approval, hold the request
|
||||
OUTBOUND_ON_MATCH_VALUES = (ON_MATCH_BLOCK, ON_MATCH_REDACT, ON_MATCH_SUPERVISE)
|
||||
# Unset resolves to supervise (fall back to block when supervise is not wired).
|
||||
DEFAULT_OUTBOUND_ON_MATCH = ON_MATCH_SUPERVISE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathMatch:
|
||||
@@ -66,8 +75,22 @@ class Route:
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
auth_scheme: str = ""
|
||||
token_env: str = ""
|
||||
git_fetch: bool = False
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
||||
outbound_on_match: str = ""
|
||||
|
||||
|
||||
LOG_OFF = 0 # no logging
|
||||
LOG_BLOCKS = 1 # log block/warn events with request context
|
||||
LOG_FULL = 2 # log block/warn events + full request and response bodies
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
routes: tuple[Route, ...]
|
||||
log: int = LOG_OFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -81,6 +104,13 @@ class Decision:
|
||||
class ScanResult:
|
||||
severity: str # "block" or "warn"
|
||||
reason: str
|
||||
location: str = "" # where the match was found, e.g. "body", "authorization header"
|
||||
context: str = "" # surrounding text with the match replaced by REDACT
|
||||
# Raw substring the detector matched. Used inside the sidecar to key the
|
||||
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written
|
||||
# to a proposal file. Empty for structural detectors (CRLF) that carry no
|
||||
# safelist-able value.
|
||||
matched: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -204,12 +234,12 @@ def _parse_detectors(
|
||||
idx: int,
|
||||
host: str,
|
||||
raw_dict: dict[str, object],
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None]:
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||
"""Parse the optional `dlp` block on a route, returning
|
||||
(outbound_detectors, inbound_detectors)."""
|
||||
(outbound_detectors, inbound_detectors, outbound_on_match)."""
|
||||
dlp_raw = raw_dict.get("dlp")
|
||||
if dlp_raw is None:
|
||||
return None, None
|
||||
return None, None, ""
|
||||
label = f"route[{idx}] ({host})"
|
||||
if not isinstance(dlp_raw, dict):
|
||||
raise ValueError(f"{label}: 'dlp' must be an object")
|
||||
@@ -246,13 +276,24 @@ def _parse_detectors(
|
||||
outbound = _parse_detector_field("outbound_detectors", OUTBOUND_DETECTOR_NAMES)
|
||||
inbound = _parse_detector_field("inbound_detectors", INBOUND_DETECTOR_NAMES)
|
||||
|
||||
on_match = ""
|
||||
on_match_raw = dlp.get("outbound_on_match")
|
||||
if on_match_raw is not None:
|
||||
if not isinstance(on_match_raw, str) or on_match_raw not in OUTBOUND_ON_MATCH_VALUES:
|
||||
raise ValueError(
|
||||
f"{label}: dlp.outbound_on_match must be one of "
|
||||
f"{', '.join(OUTBOUND_ON_MATCH_VALUES)} (got {on_match_raw!r})"
|
||||
)
|
||||
on_match = on_match_raw
|
||||
|
||||
for k in dlp:
|
||||
if k not in ("outbound_detectors", "inbound_detectors"):
|
||||
if k not in ("outbound_detectors", "inbound_detectors", "outbound_on_match"):
|
||||
raise ValueError(
|
||||
f"{label}: dlp has unknown key {k!r}; accepted keys "
|
||||
f"are 'outbound_detectors', 'inbound_detectors'"
|
||||
f"are 'outbound_detectors', 'inbound_detectors', "
|
||||
f"'outbound_on_match'"
|
||||
)
|
||||
return outbound, inbound
|
||||
return outbound, inbound, on_match
|
||||
|
||||
|
||||
def parse_routes(payload: object) -> tuple[Route, ...]:
|
||||
@@ -303,16 +344,35 @@ def _parse_one(idx: int, raw: object) -> Route:
|
||||
f"token_env={token_env!r})"
|
||||
)
|
||||
|
||||
# git-over-HTTPS policy
|
||||
git_fetch = False
|
||||
git_raw = raw_dict.get("git")
|
||||
if git_raw is not None:
|
||||
if not isinstance(git_raw, dict):
|
||||
raise ValueError(f"{label} ({host}): 'git' must be an object")
|
||||
git_dict: dict[str, object] = typing.cast(dict[str, object], git_raw)
|
||||
fetch_raw = git_dict.get("fetch", False)
|
||||
if fetch_raw is True or fetch_raw is False:
|
||||
git_fetch = fetch_raw
|
||||
else:
|
||||
raise ValueError(f"{label} ({host}): 'git.fetch' must be a boolean")
|
||||
for k in git_dict:
|
||||
if k != "fetch":
|
||||
raise ValueError(
|
||||
f"{label} ({host}): git has unknown key {k!r}; "
|
||||
"accepted key is 'fetch'"
|
||||
)
|
||||
|
||||
# dlp detectors
|
||||
outbound_detectors, inbound_detectors = _parse_detectors(
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = _parse_detectors(
|
||||
idx, host, raw_dict,
|
||||
)
|
||||
|
||||
for k in raw_dict:
|
||||
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp"):
|
||||
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp", "git"):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
||||
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp'"
|
||||
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp', 'git'"
|
||||
)
|
||||
|
||||
return Route(
|
||||
@@ -320,18 +380,89 @@ def _parse_one(idx: int, raw: object) -> Route:
|
||||
matches=matches,
|
||||
auth_scheme=auth_scheme,
|
||||
token_env=token_env,
|
||||
git_fetch=git_fetch,
|
||||
outbound_detectors=outbound_detectors,
|
||||
inbound_detectors=inbound_detectors,
|
||||
outbound_on_match=outbound_on_match,
|
||||
)
|
||||
|
||||
|
||||
def load_routes(text: str) -> tuple[Route, ...]:
|
||||
"""Parse YAML text → routes."""
|
||||
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"value": pm.value}
|
||||
if pm.type != "prefix":
|
||||
d["type"] = pm.type
|
||||
return d
|
||||
|
||||
|
||||
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"name": hm.name, "value": hm.value}
|
||||
if hm.type != "exact":
|
||||
d["type"] = hm.type
|
||||
return d
|
||||
|
||||
|
||||
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
|
||||
d: dict[str, object] = {}
|
||||
if me.paths:
|
||||
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
|
||||
if me.methods:
|
||||
d["methods"] = list(me.methods)
|
||||
if me.headers:
|
||||
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
|
||||
return d
|
||||
|
||||
|
||||
def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
||||
"""Serialize a Route to YAML-schema-compatible dict.
|
||||
|
||||
Uses the same field names the YAML parser accepts, so the output
|
||||
can be round-tripped directly into an `allow` or `egress-block`
|
||||
proposal without translation. Fields that are empty/default are
|
||||
omitted so the agent doesn't copy irrelevant keys."""
|
||||
d: dict[str, object] = {"host": r.host}
|
||||
if r.auth_scheme:
|
||||
d["auth_scheme"] = r.auth_scheme
|
||||
d["token_env"] = r.token_env
|
||||
if r.matches:
|
||||
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
||||
if r.git_fetch:
|
||||
d["git"] = {"fetch": True}
|
||||
dlp: dict[str, object] = {}
|
||||
if r.outbound_detectors is not None:
|
||||
dlp["outbound_detectors"] = list(r.outbound_detectors)
|
||||
if r.inbound_detectors is not None:
|
||||
dlp["inbound_detectors"] = list(r.inbound_detectors)
|
||||
if r.outbound_on_match:
|
||||
dlp["outbound_on_match"] = r.outbound_on_match
|
||||
if dlp:
|
||||
d["dlp"] = dlp
|
||||
return d
|
||||
|
||||
|
||||
def parse_config(payload: object) -> "Config":
|
||||
"""Parse a full egress config payload (top-level log level + routes)."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("routes payload: top-level must be an object")
|
||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||
|
||||
log_raw: object = payload_dict.get("log", LOG_OFF)
|
||||
if log_raw is True or log_raw is False or not isinstance(log_raw, int) \
|
||||
or log_raw not in (LOG_OFF, LOG_BLOCKS, LOG_FULL):
|
||||
raise ValueError(
|
||||
f"routes payload: 'log' must be {LOG_OFF}, {LOG_BLOCKS}, or {LOG_FULL}"
|
||||
)
|
||||
|
||||
routes = parse_routes(payload)
|
||||
return Config(routes=routes, log=log_raw)
|
||||
|
||||
|
||||
def load_config(text: str) -> "Config":
|
||||
"""Parse YAML text → Config (routes + log flag)."""
|
||||
try:
|
||||
payload = parse_yaml_subset(text)
|
||||
except YamlSubsetError as e:
|
||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
||||
return parse_routes(payload)
|
||||
return parse_config(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -411,6 +542,17 @@ def is_git_push_request(path: str, query: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_git_fetch_request(path: str, query: str) -> bool:
|
||||
if path.endswith("/git-upload-pack"):
|
||||
return True
|
||||
if path.endswith("/info/refs"):
|
||||
for pair in query.split("&"):
|
||||
k, _, v = pair.partition("=")
|
||||
if k == "service" and v == "git-upload-pack":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route lookup + decision
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,6 +573,7 @@ def decide(
|
||||
request_host: str,
|
||||
request_path: str,
|
||||
environ: typing.Mapping[str, str],
|
||||
*,
|
||||
request_method: str = "GET",
|
||||
request_headers: typing.Mapping[str, str] | None = None,
|
||||
) -> Decision:
|
||||
@@ -473,10 +616,86 @@ def decide(
|
||||
return Decision(action="forward")
|
||||
|
||||
|
||||
def decide_git_fetch(
|
||||
routes: typing.Sequence[Route],
|
||||
request_host: str,
|
||||
) -> Decision:
|
||||
route = match_route(routes, request_host)
|
||||
if route is not None and route.git_fetch:
|
||||
return Decision(action="forward")
|
||||
return Decision(
|
||||
action="block",
|
||||
reason=(
|
||||
"egress: git fetch/clone over HTTPS is not allowed by default; "
|
||||
"use git-gate for declared repos or set "
|
||||
"egress.routes[].git.fetch=true for explicit read-only "
|
||||
"HTTPS Git access."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DLP scan dispatch (PRD 0053)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_outbound_scan_text(
|
||||
host: str,
|
||||
path: str,
|
||||
query: str,
|
||||
headers: typing.Mapping[str, str],
|
||||
body: str,
|
||||
) -> str:
|
||||
"""Assemble all outbound request surfaces into one string for DLP scanning.
|
||||
|
||||
Covers hostname (DNS tunnelling), path, query params, all headers, body.
|
||||
"""
|
||||
parts: list[str] = [host, path]
|
||||
if query:
|
||||
parts.append(query)
|
||||
for name, value in headers.items():
|
||||
parts.append(f"{name}: {value}")
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def outbound_scan_headers(
|
||||
route: Route,
|
||||
headers: typing.Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Return request headers that should be included in outbound DLP.
|
||||
|
||||
Routes that inject sidecar-owned auth always strip the agent's
|
||||
Authorization header before forwarding. Scanning that header first
|
||||
creates false positives for provider clients that insist on sending
|
||||
their own bearer-shaped placeholder, while still not changing what
|
||||
reaches the upstream.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
skip_auth = bool(route.auth_scheme and route.token_env)
|
||||
for name, value in headers.items():
|
||||
if skip_auth and name.lower() == "authorization":
|
||||
continue
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
def build_inbound_scan_text(
|
||||
headers: typing.Mapping[str, str],
|
||||
body: str,
|
||||
) -> str:
|
||||
"""Assemble inbound response surfaces into one string for DLP scanning.
|
||||
|
||||
Covers all response headers plus body.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for name, value in headers.items():
|
||||
parts.append(f"{name}: {value}")
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _detector_enabled(
|
||||
configured: tuple[str, ...] | None,
|
||||
name: str,
|
||||
@@ -492,29 +711,103 @@ def scan_outbound(
|
||||
route: Route,
|
||||
body: str | bytes,
|
||||
environ: typing.Mapping[str, str],
|
||||
*,
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
crlf_text: str | None = None,
|
||||
) -> ScanResult | None:
|
||||
# Lazy import to avoid circular deps and keep dlp_detectors optional
|
||||
# at import time (the sidecar copies it flat alongside this file).
|
||||
try:
|
||||
from dlp_detectors import scan_token_patterns, scan_known_secrets # type: ignore[import-not-found]
|
||||
from dlp_detectors import ( # type: ignore[import-not-found]
|
||||
scan_crlf_injection,
|
||||
scan_entropy,
|
||||
scan_known_secrets,
|
||||
scan_token_patterns,
|
||||
)
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from .dlp_detectors import scan_token_patterns, scan_known_secrets # type: ignore[import-not-found]
|
||||
from .dlp_detectors import ( # type: ignore[import-not-found]
|
||||
scan_crlf_injection,
|
||||
scan_entropy,
|
||||
scan_known_secrets,
|
||||
scan_token_patterns,
|
||||
)
|
||||
|
||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
||||
# Binary bodies: latin-1 is a bijective byte↔codepoint mapping that
|
||||
# preserves every byte value, so ASCII-range secret strings remain
|
||||
# findable by str.find / regex. Prefer strict UTF-8 for valid text bodies.
|
||||
if isinstance(body, bytes):
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = body.decode("latin-1")
|
||||
else:
|
||||
text = body
|
||||
|
||||
# CRLF injection is only an attack in the request line + headers, never the
|
||||
# body: an HTTP body is delimited by Content-Length, so CRLF bytes there
|
||||
# cannot split the request. Scanning the body produces false positives on
|
||||
# legitimate form-encoded / multi-line content. Callers pass the
|
||||
# body-excluded surfaces as `crlf_text`; `None` falls back to the full text
|
||||
# for backward-compatible callers (host-side tests, websocket frames).
|
||||
crlf_target = text if crlf_text is None else crlf_text
|
||||
result = scan_crlf_injection(crlf_target)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
if _detector_enabled(route.outbound_detectors, "token_patterns"):
|
||||
result = scan_token_patterns(text)
|
||||
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
if _detector_enabled(route.outbound_detectors, "known_secrets"):
|
||||
result = scan_known_secrets(text, env=environ)
|
||||
# BOT_BOTTLE_SENSITIVE_PREFIXES lets operators add extra env prefixes
|
||||
# beyond EGRESS_TOKEN_* without changing the manifest schema.
|
||||
extra_raw = environ.get("BOT_BOTTLE_SENSITIVE_PREFIXES", "")
|
||||
extra = tuple(p for p in extra_raw.split(",") if p)
|
||||
sensitive_prefixes = ("EGRESS_TOKEN_",) + extra
|
||||
result = scan_known_secrets(
|
||||
text, location="body", env=environ,
|
||||
sensitive_prefixes=sensitive_prefixes, safe_tokens=safe_tokens,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Entropy scanning requires explicit opt-in: it is NOT part of the
|
||||
# default "all detectors" set because it produces false positives on
|
||||
# legitimate base64 / binary payloads. Routes must list "entropy" in
|
||||
# dlp.outbound_detectors to enable it.
|
||||
if (
|
||||
route.outbound_detectors is not None
|
||||
and "entropy" in route.outbound_detectors
|
||||
):
|
||||
result = scan_entropy(text, location="body")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_token_allow_payload(
|
||||
host: str,
|
||||
method: str,
|
||||
path: str,
|
||||
result: ScanResult,
|
||||
) -> str:
|
||||
"""Render the human-readable supervisor proposal body for an outbound
|
||||
token block (PRD 0062). Carries the host/method/path, the detector
|
||||
reason, and the redacted context snippet — never the raw token value."""
|
||||
lines = [
|
||||
"egress blocked an outbound request carrying a detected token",
|
||||
f"host: {host}",
|
||||
f"method: {method}",
|
||||
f"path: {path}",
|
||||
f"detector: {result.reason}",
|
||||
]
|
||||
if result.context:
|
||||
lines.append(f"context: {result.context}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def scan_inbound(
|
||||
route: Route,
|
||||
body: str | bytes,
|
||||
@@ -535,17 +828,34 @@ def scan_inbound(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LOG_BLOCKS",
|
||||
"route_to_yaml_dict",
|
||||
"LOG_FULL",
|
||||
"LOG_OFF",
|
||||
"ON_MATCH_BLOCK",
|
||||
"ON_MATCH_REDACT",
|
||||
"ON_MATCH_SUPERVISE",
|
||||
"OUTBOUND_ON_MATCH_VALUES",
|
||||
"DEFAULT_OUTBOUND_ON_MATCH",
|
||||
"Config",
|
||||
"Decision",
|
||||
"HeaderMatch",
|
||||
"MatchEntry",
|
||||
"PathMatch",
|
||||
"Route",
|
||||
"ScanResult",
|
||||
"build_inbound_scan_text",
|
||||
"build_outbound_scan_text",
|
||||
"build_token_allow_payload",
|
||||
"decide",
|
||||
"decide_git_fetch",
|
||||
"evaluate_matches",
|
||||
"is_git_push_request",
|
||||
"load_routes",
|
||||
"is_git_fetch_request",
|
||||
"load_config",
|
||||
"match_route",
|
||||
"outbound_scan_headers",
|
||||
"parse_config",
|
||||
"parse_routes",
|
||||
"scan_inbound",
|
||||
"scan_outbound",
|
||||
|
||||
+2
-2
@@ -114,7 +114,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
||||
def resolve_env(manifest: Manifest) -> ResolvedEnv:
|
||||
"""Iterate the agent's env entries:
|
||||
- secret: prompt at runtime; carry value in forwarded
|
||||
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
|
||||
@@ -124,7 +124,7 @@ def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
||||
backend injects forwarded values via its launcher's env parameter."""
|
||||
forwarded: dict[str, str] = {}
|
||||
literals: dict[str, str] = {}
|
||||
bottle = manifest.bottle_for(agent)
|
||||
bottle = manifest.bottle
|
||||
for name, raw in bottle.env.items():
|
||||
if not name:
|
||||
continue
|
||||
|
||||
+209
-24
@@ -37,7 +37,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .log import info
|
||||
from .manifest import Bottle, GitEntry
|
||||
from .manifest import ManifestBottle, ManifestGitEntry
|
||||
|
||||
|
||||
# Short network alias for git-gate inside the sidecar bundle. The
|
||||
@@ -96,9 +96,9 @@ class GitGatePlan:
|
||||
egress_network: str = ""
|
||||
|
||||
|
||||
def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]:
|
||||
def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstream, ...]:
|
||||
"""Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name
|
||||
validation already ran in `manifest.Bottle.from_dict`."""
|
||||
validation already ran in `manifest.ManifestBottle.from_dict`."""
|
||||
return tuple(
|
||||
GitGateUpstream(
|
||||
name=e.Name,
|
||||
@@ -113,7 +113,7 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
|
||||
|
||||
|
||||
def git_gate_render_gitconfig(
|
||||
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git",
|
||||
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
||||
) -> str:
|
||||
"""Render the agent's ~/.gitconfig content for git-gate
|
||||
`insteadOf` rewrites. Pure host-side, no docker / smolvm;
|
||||
@@ -204,6 +204,7 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
|
||||
" git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"",
|
||||
" git -C \"$repo\" config receive.denyCurrentBranch ignore",
|
||||
" git -C \"$repo\" config receive.advertisePushOptions true",
|
||||
" git -C \"$repo\" config http.receivepack true",
|
||||
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
|
||||
"}",
|
||||
@@ -246,6 +247,164 @@ cat > "$refs_file"
|
||||
|
||||
zero=0000000000000000000000000000000000000000
|
||||
|
||||
supervise_gitleaks_allow() {
|
||||
log_opts=$1
|
||||
ref=$2
|
||||
report_file=$(mktemp)
|
||||
if ! gitleaks git \
|
||||
--log-opts="$log_opts" \
|
||||
--no-banner \
|
||||
--redact \
|
||||
--ignore-gitleaks-allow \
|
||||
--report-format=json \
|
||||
--report-path="$report_file" \
|
||||
--exit-code 0 \
|
||||
1>&2; then
|
||||
rm -f "$report_file"
|
||||
echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
proposal_id=$(
|
||||
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
report_path = Path(sys.argv[1])
|
||||
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||
if not queue_dir or not slug:
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
raw = json.loads(report_path.read_text() or "[]")
|
||||
except json.JSONDecodeError:
|
||||
sys.exit(3)
|
||||
if not isinstance(raw, list):
|
||||
sys.exit(3)
|
||||
if not raw:
|
||||
sys.exit(0)
|
||||
|
||||
ref = os.environ.get("GITLEAKS_ALLOW_REF", "")
|
||||
lines = [
|
||||
"gitleaks inline suppression requires supervisor approval",
|
||||
f"ref: {ref}",
|
||||
"",
|
||||
]
|
||||
for i, finding in enumerate(raw, 1):
|
||||
if not isinstance(finding, dict):
|
||||
continue
|
||||
file_path = finding.get("File", "")
|
||||
line_no = finding.get("StartLine", finding.get("Line", ""))
|
||||
rule_id = finding.get("RuleID", "")
|
||||
commit = finding.get("Commit", "")
|
||||
line = finding.get("Line", "")
|
||||
lines.extend([
|
||||
f"finding {i}:",
|
||||
f" file: {file_path}",
|
||||
f" line: {line_no}",
|
||||
f" rule: {rule_id}",
|
||||
f" commit: {commit}",
|
||||
f" code: {line}",
|
||||
"",
|
||||
])
|
||||
|
||||
payload = "\n".join(lines).rstrip() + "\n"
|
||||
proposal_id = str(uuid.uuid4())
|
||||
proposal = {
|
||||
"id": proposal_id,
|
||||
"bottle_slug": slug,
|
||||
"tool": "gitleaks-allow",
|
||||
"proposed_file": payload,
|
||||
"justification": (
|
||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||
"approve only for dummy test fixtures or confirmed false positives"
|
||||
),
|
||||
"arrival_timestamp": datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
queue = Path(queue_dir)
|
||||
queue.mkdir(parents=True, exist_ok=True)
|
||||
path = queue / f"{proposal_id}.proposal.json"
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(proposal, f, indent=2)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
print(proposal_id)
|
||||
PY
|
||||
)
|
||||
rc=$?
|
||||
rm -f "$report_file"
|
||||
if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
||||
response_file="$queue_dir/${proposal_id}.response.json"
|
||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||
case "$timeout" in
|
||||
''|*[!0-9]*)
|
||||
echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
|
||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||
waited=0
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
if [ -f "$response_file" ]; then
|
||||
status=$(python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(1)
|
||||
status = raw.get("status")
|
||||
if not isinstance(status, str):
|
||||
sys.exit(1)
|
||||
print(status)
|
||||
PY
|
||||
) || status=""
|
||||
case "$status" in
|
||||
approved|modified)
|
||||
mkdir -p "$queue_dir/processed"
|
||||
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||
return 0
|
||||
;;
|
||||
rejected)
|
||||
echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Phase 1: gitleaks scan each ref's incoming commits.
|
||||
while IFS=' ' read -r old new ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
@@ -267,6 +426,9 @@ while IFS=' ' read -r old new ref; do
|
||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
|
||||
exit 1
|
||||
fi
|
||||
done < "$refs_file"
|
||||
|
||||
# Phase 2: forward each ref to the upstream (`origin`, configured
|
||||
@@ -280,15 +442,32 @@ if [ ! -f "$hostsfile" ]; then
|
||||
fi
|
||||
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10"
|
||||
|
||||
push_option_count=${GIT_PUSH_OPTION_COUNT:-0}
|
||||
case "$push_option_count" in
|
||||
''|*[!0-9]*)
|
||||
echo "git-gate: invalid GIT_PUSH_OPTION_COUNT=$push_option_count" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
set --
|
||||
i=0
|
||||
while [ "$i" -lt "$push_option_count" ]; do
|
||||
opt=$(printenv "GIT_PUSH_OPTION_$i" || :)
|
||||
set -- "$@" --push-option="$opt"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
while IFS=' ' read -r old new ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
if [ "$new" = "$zero" ]; then
|
||||
refspec=":$ref"
|
||||
elif [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
|
||||
refspec="+$new:$ref"
|
||||
else
|
||||
refspec="$new:$ref"
|
||||
fi
|
||||
echo "git-gate: forwarding $ref to origin" >&2
|
||||
if ! GIT_SSH_COMMAND="$ssh_cmd" git push origin "$refspec" 1>&2; then
|
||||
if ! GIT_SSH_COMMAND="$ssh_cmd" git push "$@" origin "$refspec" 1>&2; then
|
||||
echo "git-gate: upstream push failed for $ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -361,7 +540,7 @@ exit 0
|
||||
|
||||
|
||||
def _provision_dynamic_key(
|
||||
entry: GitEntry,
|
||||
entry: ManifestGitEntry,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
) -> str:
|
||||
@@ -371,13 +550,12 @@ def _provision_dynamic_key(
|
||||
Returns the host-side path to the private key file so the caller
|
||||
can inject it into the GitGateUpstream as `identity_file`."""
|
||||
from .deploy_key_provisioner import get_provisioner
|
||||
pk = entry.ProvisionedKey
|
||||
assert pk is not None
|
||||
token = os.environ.get(pk.token_env)
|
||||
pk = entry.Key
|
||||
token = os.environ.get(pk.forge_token_env)
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||
f" = {pk.token_env!r}: env var is not set"
|
||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||
f" = {pk.forge_token_env!r}: env var is not set"
|
||||
)
|
||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||
@@ -402,7 +580,7 @@ def _provision_dynamic_key(
|
||||
return str(key_file)
|
||||
|
||||
|
||||
def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None:
|
||||
def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> None:
|
||||
"""Revoke all deploy keys provisioned for `bottle` during prepare.
|
||||
|
||||
Called at teardown after containers stop. Raises if any revocation
|
||||
@@ -410,18 +588,18 @@ def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None:
|
||||
address manually."""
|
||||
from .deploy_key_provisioner import get_provisioner
|
||||
for entry in bottle.git:
|
||||
if entry.ProvisionedKey is None:
|
||||
if entry.Key.provider != "gitea":
|
||||
continue
|
||||
pk = entry.ProvisionedKey
|
||||
pk = entry.Key
|
||||
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
||||
if not id_file.exists():
|
||||
continue
|
||||
key_id = id_file.read_text().strip()
|
||||
token = os.environ.get(pk.token_env)
|
||||
token = os.environ.get(pk.forge_token_env)
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||
f" = {pk.token_env!r}: env var is not set;"
|
||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||
f" = {pk.forge_token_env!r}: env var is not set;"
|
||||
f" cannot revoke deploy key {key_id}"
|
||||
)
|
||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||
@@ -434,18 +612,26 @@ def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None:
|
||||
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
||||
|
||||
|
||||
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
|
||||
"""Return the host-side SSH identity file path for this entry.
|
||||
For gitea entries, provisions a fresh deploy key first."""
|
||||
if entry.Key.provider == "gitea":
|
||||
return _provision_dynamic_key(entry, slug, stage_dir)
|
||||
return entry.IdentityFile
|
||||
|
||||
|
||||
class GitGate(ABC):
|
||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||
(upstream lift + entrypoint/hook render); the sidecar's
|
||||
start/stop lifecycle is backend-specific and lives on concrete
|
||||
subclasses."""
|
||||
|
||||
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> GitGatePlan:
|
||||
def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan:
|
||||
"""Compute the upstream table from `bottle.git` and write the
|
||||
entrypoint, pre-receive hook, and access-hook scripts (mode
|
||||
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
||||
|
||||
For `provisioned_key` entries, also generates and registers
|
||||
For `gitea` key entries, also generates and registers
|
||||
a fresh deploy key via the forge API and writes the private key
|
||||
+ key ID to `stage_dir`.
|
||||
|
||||
@@ -454,11 +640,10 @@ class GitGate(ABC):
|
||||
before passing the plan to `.start`."""
|
||||
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
||||
for i, entry in enumerate(bottle.git):
|
||||
if entry.ProvisionedKey is not None:
|
||||
key_file = _provision_dynamic_key(entry, slug, stage_dir)
|
||||
upstreams_list[i] = dataclasses.replace(
|
||||
upstreams_list[i], identity_file=key_file
|
||||
)
|
||||
upstreams_list[i] = dataclasses.replace(
|
||||
upstreams_list[i],
|
||||
identity_file=_resolve_identity_file(entry, slug, stage_dir),
|
||||
)
|
||||
upstreams = tuple(upstreams_list)
|
||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
||||
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
||||
|
||||
@@ -19,8 +19,8 @@ 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
|
||||
# Bound memory use while still allowing ordinary git push packfiles.
|
||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
|
||||
+96
-10
@@ -1,21 +1,107 @@
|
||||
"""Tiny logging wrappers. All output goes to stderr."""
|
||||
"""Tiny logging wrappers. All output goes to stderr.
|
||||
|
||||
Two capabilities layer onto the bare wrappers (issue #252):
|
||||
|
||||
- **Levels.** `debug` / `info` / `warn` / `error` carry an ordered
|
||||
severity. Output is gated by `BOT_BOTTLE_LOG_LEVEL` (debug | info |
|
||||
warn | error; default `info`). A message emits when its severity is
|
||||
at or above the threshold, so `debug` is silent by default and
|
||||
`error` always surfaces (nothing sits above it) — which keeps the
|
||||
fatal `die` path visible regardless of the configured level.
|
||||
|
||||
- **Context.** Every wrapper takes an optional `context` mapping that
|
||||
renders as a parseable ` [k=v ...]` suffix (keys sorted; values with
|
||||
whitespace/quotes are quoted), so failures can be filtered and
|
||||
correlated instead of being flat strings.
|
||||
|
||||
With no `context` and the default level, output is byte-identical to the
|
||||
original `bot-bottle: <msg>` / `bot-bottle: warning: <msg>` /
|
||||
`bot-bottle: error: <msg>` lines — the 100+ existing call sites are
|
||||
unaffected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
from typing import Mapping, NoReturn
|
||||
|
||||
# Ordered severities. Gaps left between values so intermediate levels
|
||||
# can be added later without renumbering.
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
WARN = 30
|
||||
ERROR = 40
|
||||
|
||||
_LEVEL_NAMES: dict[str, int] = {
|
||||
"debug": DEBUG,
|
||||
"info": INFO,
|
||||
"warn": WARN,
|
||||
"warning": WARN,
|
||||
"error": ERROR,
|
||||
}
|
||||
|
||||
# Default threshold when BOT_BOTTLE_LOG_LEVEL is unset or unrecognised.
|
||||
_DEFAULT_THRESHOLD = INFO
|
||||
|
||||
_LOG_LEVEL_ENV = "BOT_BOTTLE_LOG_LEVEL"
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
print(f"bot-bottle: {msg}", file=sys.stderr)
|
||||
def _threshold() -> int:
|
||||
"""Resolve the active level threshold from the environment.
|
||||
|
||||
Read per-call (not cached) so the level can be changed at runtime
|
||||
and so tests can patch `os.environ` without a reload. Unknown values
|
||||
fall back to the default rather than raising — logging must never be
|
||||
the thing that crashes the process."""
|
||||
raw = os.environ.get(_LOG_LEVEL_ENV, "")
|
||||
return _LEVEL_NAMES.get(raw.strip().lower(), _DEFAULT_THRESHOLD)
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f"bot-bottle: warning: {msg}", file=sys.stderr)
|
||||
def _format_context(context: Mapping[str, object] | None) -> str:
|
||||
"""Render a context mapping as a ` [k=v k2=v2]` suffix.
|
||||
|
||||
Keys are sorted for stable, diffable output. Values that are empty or
|
||||
contain whitespace or a quote are wrapped in double quotes (with inner
|
||||
quotes escaped) so each `k=v` pair stays parseable. Empty/None context
|
||||
renders as the empty string."""
|
||||
if not context:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for key in sorted(context):
|
||||
value = str(context[key])
|
||||
if value == "" or any(ch.isspace() for ch in value) or '"' in value:
|
||||
value = '"' + value.replace('"', '\\"') + '"'
|
||||
parts.append(f"{key}={value}")
|
||||
return " [" + " ".join(parts) + "]"
|
||||
|
||||
|
||||
def error(msg: str) -> None:
|
||||
print(f"bot-bottle: error: {msg}", file=sys.stderr)
|
||||
def _emit(
|
||||
level: int,
|
||||
label: str,
|
||||
msg: str,
|
||||
context: Mapping[str, object] | None,
|
||||
) -> None:
|
||||
if level < _threshold():
|
||||
return
|
||||
prefix = f"{label}: " if label else ""
|
||||
sys.stderr.write(f"bot-bottle: {prefix}{msg}{_format_context(context)}\n")
|
||||
|
||||
|
||||
def debug(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(DEBUG, "debug", msg, context)
|
||||
|
||||
|
||||
def info(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(INFO, "", msg, context)
|
||||
|
||||
|
||||
def warn(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(WARN, "warning", msg, context)
|
||||
|
||||
|
||||
def error(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(ERROR, "error", msg, context)
|
||||
|
||||
|
||||
class Die(SystemExit):
|
||||
@@ -31,6 +117,6 @@ class Die(SystemExit):
|
||||
self.message = message
|
||||
|
||||
|
||||
def die(msg: str) -> NoReturn:
|
||||
error(msg)
|
||||
def die(msg: str, *, context: Mapping[str, object] | None = None) -> NoReturn:
|
||||
error(msg, context=context)
|
||||
raise Die(1, msg)
|
||||
|
||||
+229
-133
@@ -19,7 +19,7 @@ Bottle schema (frontmatter):
|
||||
repos: { <name>: <git-gate-entry>, ... } # optional
|
||||
egress: { routes: [ <egress-route>, ... ] }
|
||||
# route keys: host, matches, auth, role, dlp
|
||||
supervise: <bool> # optional
|
||||
supervise: <bool> # optional (default true)
|
||||
|
||||
Agent schema (frontmatter):
|
||||
bottle: <bottle-name> # required
|
||||
@@ -36,10 +36,23 @@ 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.
|
||||
Two types are exported:
|
||||
|
||||
ManifestIndex — the multi-agent/bottle collection returned by
|
||||
resolve() and from_json_obj(). Used for agent
|
||||
selection (all_agent_names), validation
|
||||
(require_agent), and lazy loading (load_for_agent).
|
||||
This is the pre-preflight form.
|
||||
|
||||
Manifest — a single-agent/bottle value type holding exactly
|
||||
one agent: ManifestAgent and one bottle:
|
||||
ManifestBottle (with the agent's git-gate.user
|
||||
already overlaid). Returned by load_for_agent().
|
||||
This is the post-preflight form passed to backends.
|
||||
|
||||
ManifestIndex.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
|
||||
@@ -50,26 +63,28 @@ from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from .manifest_util import ManifestError, as_json_object
|
||||
from .manifest_agent import Agent, AgentProvider
|
||||
from .manifest_agent import ManifestAgent, ManifestAgentProvider
|
||||
from .manifest_egress import (
|
||||
EGRESS_AUTH_SCHEMES,
|
||||
EgressConfig,
|
||||
EgressRoute,
|
||||
ManifestEgressConfig,
|
||||
ManifestEgressRoute,
|
||||
)
|
||||
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
|
||||
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, 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",
|
||||
"ManifestGitEntry",
|
||||
"ManifestGitUser",
|
||||
"ManifestKeyConfig",
|
||||
"ManifestAgentProvider",
|
||||
"EGRESS_AUTH_SCHEMES",
|
||||
"EgressRoute",
|
||||
"EgressConfig",
|
||||
"Agent",
|
||||
"Bottle",
|
||||
"ManifestEgressRoute",
|
||||
"ManifestEgressConfig",
|
||||
"ManifestAgent",
|
||||
"ManifestBottle",
|
||||
"ManifestIndex",
|
||||
"Manifest",
|
||||
]
|
||||
|
||||
@@ -86,26 +101,26 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bottle:
|
||||
class ManifestBottle:
|
||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||
agent_provider: AgentProvider = field(default_factory=AgentProvider)
|
||||
git: tuple[GitEntry, ...] = ()
|
||||
agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
# 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 MCP
|
||||
# tools to the agent (egress-block, capability-block) 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
|
||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
||||
# default, issue #249), the launch step brings up a supervise
|
||||
# sidecar that exposes MCP tools to the agent (egress-block,
|
||||
# capability-block) plus mounts the current-config dir read-only
|
||||
# into the agent at /etc/bot-bottle/current-config. Set
|
||||
# `supervise: false` to skip the sidecar and mount.
|
||||
supervise: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, raw: object) -> "Bottle":
|
||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||
d = as_json_object(raw, f"bottle '{name}'")
|
||||
|
||||
if "runtime" in d:
|
||||
@@ -157,25 +172,25 @@ class Bottle:
|
||||
)
|
||||
env[var] = value
|
||||
|
||||
git: tuple[GitEntry, ...] = ()
|
||||
git_user = GitUser()
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
git_user = ManifestGitUser()
|
||||
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"])
|
||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||
if "agent_provider" in d
|
||||
else AgentProvider()
|
||||
else ManifestAgentProvider()
|
||||
)
|
||||
|
||||
egress = (
|
||||
EgressConfig.from_dict(name, d["egress"])
|
||||
ManifestEgressConfig.from_dict(name, d["egress"])
|
||||
if "egress" in d
|
||||
else EgressConfig()
|
||||
else ManifestEgressConfig()
|
||||
)
|
||||
|
||||
supervise_raw = d.get("supervise", False)
|
||||
supervise_raw = d.get("supervise", True)
|
||||
if not isinstance(supervise_raw, bool):
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' supervise must be a boolean "
|
||||
@@ -188,14 +203,64 @@ class Bottle:
|
||||
)
|
||||
|
||||
|
||||
def _merge_git_user(
|
||||
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
||||
) -> ManifestGitUser:
|
||||
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
|
||||
if agent_user.is_empty():
|
||||
return base_user
|
||||
return ManifestGitUser(
|
||||
name=agent_user.name or base_user.name,
|
||||
email=agent_user.email or base_user.email,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
bottles: Mapping[str, Bottle]
|
||||
agents: Mapping[str, Agent]
|
||||
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
|
||||
|
||||
`bottle` is the effective bottle with the agent's git-gate.user already
|
||||
overlaid per-field (agent wins on non-empty). Backends and provisioners
|
||||
use this directly — no agent_name lookup needed."""
|
||||
|
||||
agent: ManifestAgent
|
||||
bottle: ManifestBottle
|
||||
|
||||
def git_identity_summary(self) -> str | None:
|
||||
"""One-line effective git identity with per-field provenance, e.g.
|
||||
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
||||
Returns None when neither agent nor bottle sets an identity."""
|
||||
over = self.agent.git_user # agent's declared git_user (pre-merge)
|
||||
merged = self.bottle.git_user # effective git_user (post-merge)
|
||||
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)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestIndex:
|
||||
"""Multi-agent/bottle collection. The pre-preflight form.
|
||||
|
||||
In lazy mode (from resolve()/from_md_dirs()) only filenames are scanned;
|
||||
no file content is read. In eager mode (from from_json_obj()) all agents
|
||||
and bottles are pre-parsed. Call load_for_agent() to get a single-value
|
||||
Manifest ready for backend use."""
|
||||
|
||||
bottles: Mapping[str, ManifestBottle]
|
||||
agents: Mapping[str, ManifestAgent]
|
||||
# Set by from_md_dirs; None in from_json_obj (test/programmatic) mode.
|
||||
# Stores the manifest root dirs so load_for_agent can locate files later.
|
||||
home_md: Path | None = field(default=None)
|
||||
cwd_md: Path | None = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
|
||||
"""Walk the per-file manifest tree and build a Manifest.
|
||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
|
||||
"""Walk the per-file manifest tree and build a ManifestIndex.
|
||||
|
||||
Layout (PRD 0011):
|
||||
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||
@@ -208,7 +273,7 @@ class Manifest:
|
||||
boundary.
|
||||
|
||||
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||
returns an empty manifest instead of dying. This is for
|
||||
returns an empty index instead of dying. This is for
|
||||
passive UI surfaces like the dashboard, which can still
|
||||
monitor already-running agents without launch config.
|
||||
|
||||
@@ -247,25 +312,16 @@ class Manifest:
|
||||
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.
|
||||
) -> "ManifestIndex":
|
||||
"""Return a names-only ManifestIndex. No file content is read; only
|
||||
filenames are scanned for the agent selector. Full parsing happens
|
||||
later, per-agent, via `load_for_agent`.
|
||||
|
||||
Used by tests to build a Manifest from fixture directories
|
||||
A `bottles/` subdir under `cwd_dir` is logged as a warning and
|
||||
ignored — the filesystem layout IS the trust boundary.
|
||||
|
||||
Used by tests to build a ManifestIndex 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():
|
||||
@@ -279,17 +335,11 @@ class Manifest:
|
||||
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)
|
||||
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
|
||||
|
||||
@classmethod
|
||||
def from_json_obj(cls, obj: object) -> "Manifest":
|
||||
"""Validate and build a Manifest from a raw JSON-like dict."""
|
||||
def from_json_obj(cls, obj: object) -> "ManifestIndex":
|
||||
"""Validate and build a ManifestIndex 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'")
|
||||
@@ -305,80 +355,126 @@ class Manifest:
|
||||
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()
|
||||
agents: dict[str, ManifestAgent] = {
|
||||
n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
|
||||
}
|
||||
return cls(bottles=bottles, agents=agents)
|
||||
|
||||
@property
|
||||
def all_agent_names(self) -> list[str]:
|
||||
"""Sorted list of all discoverable agent names.
|
||||
|
||||
In names-only mode (from resolve/from_md_dirs) this scans agent
|
||||
filenames without reading their content. In eager mode (from
|
||||
from_json_obj) it returns the pre-parsed agents' names."""
|
||||
if self.home_md is not None:
|
||||
from .manifest_loader import scan_agent_names
|
||||
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
||||
cwd_names: set[str] = set()
|
||||
if self.cwd_md is not None:
|
||||
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
|
||||
return sorted(home_names | cwd_names)
|
||||
return sorted(self.agents.keys())
|
||||
|
||||
def load_for_agent(self, agent_name: str) -> "Manifest":
|
||||
"""Parse the named agent and its bottle; return a single-value Manifest.
|
||||
|
||||
In lazy mode (from resolve/from_md_dirs) the agent file and its
|
||||
bottle chain are read from disk for the first time here. In eager
|
||||
mode (from_json_obj) the data is already parsed; this just filters
|
||||
down to the requested agent and its bottle.
|
||||
|
||||
The returned Manifest.bottle has the agent's git-gate.user already
|
||||
overlaid (agent wins on non-empty, per-field).
|
||||
|
||||
Always raises ManifestError if the agent is unknown or invalid.
|
||||
Backends call this at preflight inside _validate."""
|
||||
if self.home_md is None:
|
||||
# Eager manifest (from_json_obj): data already parsed; filter to
|
||||
# the one requested agent and its bottle so the returned Manifest
|
||||
# always holds exactly one agent and one bottle regardless of path.
|
||||
if agent_name not in self.agents:
|
||||
available = ", ".join(sorted(self.agents.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' not defined. Available: {available}"
|
||||
)
|
||||
agent = self.agents[agent_name]
|
||||
raw_bottle = self.bottles[agent.bottle]
|
||||
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||
bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
|
||||
return Manifest(agent=agent, bottle=bottle)
|
||||
|
||||
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
|
||||
from .manifest_schema import validate_agent_frontmatter_keys
|
||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
# Locate the agent file; cwd wins over home on name collision.
|
||||
home_agents = scan_agent_names(self.home_md / "agents")
|
||||
cwd_agents: dict[str, Path] = {}
|
||||
if self.cwd_md is not None:
|
||||
cwd_agents = scan_agent_names(self.cwd_md / "agents")
|
||||
merged_agents = {**home_agents, **cwd_agents}
|
||||
|
||||
if agent_name not in merged_agents:
|
||||
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' not defined. Available: {available}"
|
||||
)
|
||||
|
||||
agent_path = merged_agents[agent_name]
|
||||
try:
|
||||
fm, body = parse_frontmatter(agent_path.read_text())
|
||||
except OSError as e:
|
||||
raise ManifestError(f"could not read {agent_path}: {e}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{agent_path}: {e}") from e
|
||||
|
||||
validate_agent_frontmatter_keys(agent_path, fm.keys())
|
||||
|
||||
bottle_name = fm.get("bottle")
|
||||
if not isinstance(bottle_name, str) or not bottle_name:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' must declare a 'bottle' field "
|
||||
f"naming a defined bottle"
|
||||
)
|
||||
|
||||
# Load the bottle chain (may raise ManifestError).
|
||||
bottles_dir = self.home_md / "bottles"
|
||||
raw_bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
|
||||
|
||||
# Build and validate the full ManifestAgent.
|
||||
agent_dict: dict[str, object] = {
|
||||
"bottle": bottle_name,
|
||||
"skills": fm.get("skills", []),
|
||||
"prompt": body.strip(),
|
||||
}
|
||||
if "git-gate" in fm:
|
||||
agent_dict["git-gate"] = fm["git-gate"]
|
||||
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
|
||||
|
||||
merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||
bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
|
||||
return Manifest(agent=agent, bottle=bottle)
|
||||
|
||||
def has_agent(self, name: str) -> bool:
|
||||
return name in self.agents
|
||||
|
||||
def require_agent(self, name: str) -> None:
|
||||
"""Check that `name` is a discoverable agent. In names-only mode
|
||||
this checks whether the .md file exists; in eager mode it checks
|
||||
the pre-parsed agents dict. Does NOT parse file content."""
|
||||
if self.has_agent(name):
|
||||
return
|
||||
available = ", ".join(self.agents.keys())
|
||||
if available:
|
||||
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
|
||||
raise ManifestError(msg)
|
||||
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}"
|
||||
if self.home_md is not None:
|
||||
# Names-only mode: check file existence without parsing.
|
||||
home_path = self.home_md / "agents" / f"{name}.md"
|
||||
cwd_path = (
|
||||
self.cwd_md / "agents" / f"{name}.md"
|
||||
if self.cwd_md else None
|
||||
)
|
||||
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,
|
||||
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
|
||||
return
|
||||
available = ", ".join(self.all_agent_names) or "(none)"
|
||||
raise ManifestError(
|
||||
f"agent '{name}' not defined. Available: {available}"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
+139
-16
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
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_git import ManifestGitUser
|
||||
from .manifest_schema import AGENT_MODEL_KEYS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentProvider:
|
||||
class ManifestAgentProvider:
|
||||
"""Provider/template for the agent process inside a bottle.
|
||||
|
||||
`template` selects a built-in launch/runtime contract. `dockerfile`
|
||||
@@ -33,15 +33,23 @@ class AgentProvider:
|
||||
dockerfile: str = ""
|
||||
auth_token: str = ""
|
||||
forward_host_credentials: bool = False
|
||||
settings: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestAgentProvider":
|
||||
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"}:
|
||||
if k not in {
|
||||
"template",
|
||||
"dockerfile",
|
||||
"auth_token",
|
||||
"forward_host_credentials",
|
||||
"settings",
|
||||
}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
|
||||
f"allowed: template, dockerfile, auth_token, forward_host_credentials"
|
||||
"allowed: template, dockerfile, auth_token, "
|
||||
"forward_host_credentials, settings"
|
||||
)
|
||||
template = d.get("template", "claude")
|
||||
if not isinstance(template, str) or not template:
|
||||
@@ -49,11 +57,6 @@ class AgentProvider:
|
||||
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(
|
||||
@@ -66,6 +69,12 @@ class AgentProvider:
|
||||
f"bottle '{bottle_name}' agent_provider.auth_token must be a "
|
||||
f"string (was {type(auth_token).__name__})"
|
||||
)
|
||||
if auth_token and template not in PROVIDER_TEMPLATES:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.auth_token is only "
|
||||
f"supported for built-in templates "
|
||||
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
||||
)
|
||||
if auth_token and template != "claude":
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.auth_token is only "
|
||||
@@ -77,21 +86,29 @@ class AgentProvider:
|
||||
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 not in PROVIDER_TEMPLATES:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||
f"is only supported for built-in templates "
|
||||
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
||||
)
|
||||
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'"
|
||||
)
|
||||
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
||||
return cls(
|
||||
template=template,
|
||||
dockerfile=dockerfile,
|
||||
auth_token=auth_token,
|
||||
forward_host_credentials=forward_host_credentials,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Agent:
|
||||
class ManifestAgent:
|
||||
bottle: str
|
||||
skills: tuple[str, ...] = ()
|
||||
prompt: str = ""
|
||||
@@ -99,10 +116,10 @@ class Agent:
|
||||
# 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()
|
||||
git_user: ManifestGitUser = ManifestGitUser()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
|
||||
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
|
||||
d = as_json_object(raw, f"agent '{name}'")
|
||||
unknown = set(d.keys()) - AGENT_MODEL_KEYS
|
||||
if unknown:
|
||||
@@ -157,7 +174,7 @@ class Agent:
|
||||
|
||||
# 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_user = ManifestGitUser()
|
||||
git_raw = d.get("git-gate")
|
||||
if git_raw is not None:
|
||||
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
|
||||
@@ -170,6 +187,112 @@ class Agent:
|
||||
f"(it carries credentials and host trust)."
|
||||
)
|
||||
if "user" in gd:
|
||||
git_user = GitUser.from_dict(name, gd["user"])
|
||||
git_user = ManifestGitUser.from_dict(name, gd["user"])
|
||||
|
||||
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
|
||||
|
||||
|
||||
def _parse_provider_settings(
|
||||
bottle_name: str,
|
||||
template: str,
|
||||
raw: object,
|
||||
) -> dict[str, object]:
|
||||
if raw is None:
|
||||
return {}
|
||||
settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings")
|
||||
|
||||
common_allowed = {"startup_args"}
|
||||
pi_allowed = {
|
||||
"provider",
|
||||
"base_url",
|
||||
"api",
|
||||
"api_key",
|
||||
"api_key_env",
|
||||
"models",
|
||||
"context_window",
|
||||
"max_tokens_field",
|
||||
"max_tokens",
|
||||
"supports_developer_role",
|
||||
"supports_reasoning_effort",
|
||||
}
|
||||
if template == "pi":
|
||||
allowed = common_allowed | pi_allowed
|
||||
elif template in ("claude", "codex"):
|
||||
allowed = common_allowed
|
||||
elif template not in PROVIDER_TEMPLATES:
|
||||
return dict(settings)
|
||||
else:
|
||||
allowed = common_allowed
|
||||
|
||||
for key in settings:
|
||||
if key not in allowed:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings has unknown "
|
||||
f"key {key!r}; allowed: {', '.join(sorted(allowed))}"
|
||||
)
|
||||
startup_args = settings.get("startup_args")
|
||||
if startup_args is not None:
|
||||
if not isinstance(startup_args, list):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.startup_args "
|
||||
f"must be an array of strings"
|
||||
)
|
||||
for i, arg in enumerate(startup_args):
|
||||
if not isinstance(arg, str) or not arg:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings."
|
||||
f"startup_args[{i}] must be a non-empty string"
|
||||
)
|
||||
if template != "pi":
|
||||
return dict(settings)
|
||||
|
||||
for key in ("provider", "base_url", "api", "api_key", "api_key_env"):
|
||||
value = settings.get(key)
|
||||
if value is not None and (not isinstance(value, str) or not value):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.{key} must "
|
||||
"be a non-empty string"
|
||||
)
|
||||
max_tokens_field = settings.get("max_tokens_field")
|
||||
if max_tokens_field is not None and max_tokens_field not in (
|
||||
"max_tokens", "max_completion_tokens",
|
||||
):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.max_tokens_field "
|
||||
"must be 'max_tokens' or 'max_completion_tokens'"
|
||||
)
|
||||
if settings.get("api_key") is not None and settings.get("api_key_env") is not None:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings may set either "
|
||||
"api_key or api_key_env, not both"
|
||||
)
|
||||
models = settings.get("models")
|
||||
if models is not None:
|
||||
if not isinstance(models, list) or not models:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.models must "
|
||||
"be a non-empty array of strings"
|
||||
)
|
||||
for i, model in enumerate(models):
|
||||
if not isinstance(model, str) or not model:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.models[{i}] "
|
||||
"must be a non-empty string"
|
||||
)
|
||||
for key in ("supports_developer_role", "supports_reasoning_effort"):
|
||||
value = settings.get(key)
|
||||
if value is not None and not isinstance(value, bool):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.{key} must "
|
||||
f"be a boolean (was {type(value).__name__})"
|
||||
)
|
||||
for key in ("context_window", "max_tokens"):
|
||||
value = settings.get(key)
|
||||
if value is not None and (
|
||||
not isinstance(value, int) or isinstance(value, bool) or value <= 0
|
||||
):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.{key} must "
|
||||
f"be a positive integer (was {type(value).__name__})"
|
||||
)
|
||||
return dict(settings)
|
||||
|
||||
@@ -21,10 +21,13 @@ VALID_METHODS = frozenset({
|
||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets"})
|
||||
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
||||
|
||||
# What the proxy does on an outbound token match (PRD 0062).
|
||||
OUTBOUND_ON_MATCH_VALUES = ("block", "redact", "supervise")
|
||||
|
||||
|
||||
def validate_egress_routes(
|
||||
bottle_name: str,
|
||||
routes: tuple[EgressRoute, ...],
|
||||
routes: tuple[ManifestEgressRoute, ...],
|
||||
) -> None:
|
||||
seen_hosts: dict[str, None] = {}
|
||||
for r in routes:
|
||||
@@ -38,37 +41,39 @@ def validate_egress_routes(
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathMatch:
|
||||
class ManifestPathMatch:
|
||||
Type: str = "prefix"
|
||||
Value: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeaderMatch:
|
||||
class ManifestHeaderMatch:
|
||||
Name: str = ""
|
||||
Value: str = ""
|
||||
Type: str = "exact"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchEntry:
|
||||
Paths: tuple[PathMatch, ...] = ()
|
||||
class ManifestMatchEntry:
|
||||
Paths: tuple[ManifestPathMatch, ...] = ()
|
||||
Methods: tuple[str, ...] = ()
|
||||
Headers: tuple[HeaderMatch, ...] = ()
|
||||
Headers: tuple[ManifestHeaderMatch, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EgressRoute:
|
||||
class ManifestEgressRoute:
|
||||
Host: str
|
||||
Matches: tuple[MatchEntry, ...] = ()
|
||||
Matches: tuple[ManifestMatchEntry, ...] = ()
|
||||
AuthScheme: str = ""
|
||||
TokenRef: str = ""
|
||||
Role: tuple[str, ...] = ()
|
||||
GitFetch: bool = False
|
||||
OutboundDetectors: tuple[str, ...] | None = None
|
||||
InboundDetectors: tuple[str, ...] | None = None
|
||||
OutboundOnMatch: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
|
||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
|
||||
label = f"bottle '{bottle_name}' egress.routes[{idx}]"
|
||||
d = as_json_object(raw, label)
|
||||
host = d.get("host")
|
||||
@@ -76,7 +81,7 @@ class EgressRoute:
|
||||
raise ManifestError(f"{label} missing required string field 'host'")
|
||||
|
||||
# --- matches ---
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
matches: tuple[ManifestMatchEntry, ...] = ()
|
||||
matches_raw = d.get("matches")
|
||||
if matches_raw is not None:
|
||||
if not isinstance(matches_raw, list):
|
||||
@@ -85,7 +90,7 @@ class EgressRoute:
|
||||
f"(was {type(matches_raw).__name__})"
|
||||
)
|
||||
matches_list = cast(list[object], matches_raw)
|
||||
entries: list[MatchEntry] = []
|
||||
entries: list[ManifestMatchEntry] = []
|
||||
for k, entry_raw in enumerate(matches_list):
|
||||
entries.append(
|
||||
_parse_match_entry(label, k, entry_raw)
|
||||
@@ -160,16 +165,36 @@ class EgressRoute:
|
||||
# --- dlp ---
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
outbound_on_match = ""
|
||||
if "dlp" in d:
|
||||
outbound_detectors, inbound_detectors = _parse_dlp_block(
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = _parse_dlp_block(
|
||||
label, d.get("dlp"),
|
||||
)
|
||||
|
||||
# --- git-over-HTTPS policy ---
|
||||
git_fetch = False
|
||||
if "git" in d:
|
||||
git_d = as_json_object(d.get("git"), f"{label} git")
|
||||
raw_fetch = git_d.get("fetch", False)
|
||||
if isinstance(raw_fetch, bool):
|
||||
git_fetch = raw_fetch
|
||||
else:
|
||||
raise ManifestError(
|
||||
f"{label} git.fetch must be a boolean "
|
||||
f"(was {type(raw_fetch).__name__})"
|
||||
)
|
||||
for k in git_d:
|
||||
if k != "fetch":
|
||||
raise ManifestError(
|
||||
f"{label} git has unknown key {k!r}; "
|
||||
f"only 'fetch' is accepted"
|
||||
)
|
||||
|
||||
for k in d:
|
||||
if k not in ("host", "matches", "auth", "role", "dlp"):
|
||||
if k not in ("host", "matches", "auth", "role", "dlp", "git"):
|
||||
raise ManifestError(
|
||||
f"{label} has unknown key {k!r}; accepted keys are "
|
||||
f"'host', 'matches', 'auth', 'role', 'dlp'"
|
||||
f"'host', 'matches', 'auth', 'role', 'dlp', 'git'"
|
||||
)
|
||||
|
||||
return cls(
|
||||
@@ -178,24 +203,26 @@ class EgressRoute:
|
||||
AuthScheme=auth_scheme,
|
||||
TokenRef=token_ref,
|
||||
Role=roles,
|
||||
GitFetch=git_fetch,
|
||||
OutboundDetectors=outbound_detectors,
|
||||
InboundDetectors=inbound_detectors,
|
||||
OutboundOnMatch=outbound_on_match,
|
||||
)
|
||||
|
||||
|
||||
def _parse_match_entry(
|
||||
route_label: str, k: int, raw: object,
|
||||
) -> MatchEntry:
|
||||
) -> ManifestMatchEntry:
|
||||
label = f"{route_label} matches[{k}]"
|
||||
d = as_json_object(raw, label)
|
||||
|
||||
paths: tuple[PathMatch, ...] = ()
|
||||
paths: tuple[ManifestPathMatch, ...] = ()
|
||||
paths_raw = d.get("paths")
|
||||
if paths_raw is not None:
|
||||
if not isinstance(paths_raw, list):
|
||||
raise ManifestError(f"{label} paths must be an array")
|
||||
paths_list = cast(list[object], paths_raw)
|
||||
parsed_paths: list[PathMatch] = []
|
||||
parsed_paths: list[ManifestPathMatch] = []
|
||||
for j, p_raw in enumerate(paths_list):
|
||||
parsed_paths.append(_parse_path_match(label, j, p_raw))
|
||||
paths = tuple(parsed_paths)
|
||||
@@ -220,13 +247,13 @@ def _parse_match_entry(
|
||||
normalised.append(upper)
|
||||
methods = tuple(normalised)
|
||||
|
||||
headers: tuple[HeaderMatch, ...] = ()
|
||||
headers: tuple[ManifestHeaderMatch, ...] = ()
|
||||
headers_raw = d.get("headers")
|
||||
if headers_raw is not None:
|
||||
if not isinstance(headers_raw, list):
|
||||
raise ManifestError(f"{label} headers must be an array")
|
||||
headers_list = cast(list[object], headers_raw)
|
||||
parsed_headers: list[HeaderMatch] = []
|
||||
parsed_headers: list[ManifestHeaderMatch] = []
|
||||
for j, h_raw in enumerate(headers_list):
|
||||
parsed_headers.append(_parse_header_match(label, j, h_raw))
|
||||
headers = tuple(parsed_headers)
|
||||
@@ -235,12 +262,12 @@ def _parse_match_entry(
|
||||
if key not in ("paths", "methods", "headers"):
|
||||
raise ManifestError(f"{label} has unknown key {key!r}")
|
||||
|
||||
return MatchEntry(Paths=paths, Methods=methods, Headers=headers)
|
||||
return ManifestMatchEntry(Paths=paths, Methods=methods, Headers=headers)
|
||||
|
||||
|
||||
def _parse_path_match(
|
||||
entry_label: str, j: int, raw: object,
|
||||
) -> PathMatch:
|
||||
) -> ManifestPathMatch:
|
||||
label = f"{entry_label} paths[{j}]"
|
||||
d = as_json_object(raw, label)
|
||||
ptype = d.get("type", "prefix")
|
||||
@@ -266,12 +293,12 @@ def _parse_path_match(
|
||||
for k in d:
|
||||
if k not in ("type", "value"):
|
||||
raise ManifestError(f"{label} has unknown key {k!r}")
|
||||
return PathMatch(Type=ptype, Value=value)
|
||||
return ManifestPathMatch(Type=ptype, Value=value)
|
||||
|
||||
|
||||
def _parse_header_match(
|
||||
entry_label: str, j: int, raw: object,
|
||||
) -> HeaderMatch:
|
||||
) -> ManifestHeaderMatch:
|
||||
label = f"{entry_label} headers[{j}]"
|
||||
d = as_json_object(raw, label)
|
||||
name = d.get("name")
|
||||
@@ -296,13 +323,13 @@ def _parse_header_match(
|
||||
for k in d:
|
||||
if k not in ("name", "value", "type"):
|
||||
raise ManifestError(f"{label} has unknown key {k!r}")
|
||||
return HeaderMatch(Name=name, Value=value, Type=htype)
|
||||
return ManifestHeaderMatch(Name=name, Value=value, Type=htype)
|
||||
|
||||
|
||||
def _parse_dlp_block(
|
||||
route_label: str,
|
||||
raw: object,
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None]:
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||
label = f"{route_label} dlp"
|
||||
d = as_json_object(raw, label)
|
||||
|
||||
@@ -337,24 +364,39 @@ def _parse_dlp_block(
|
||||
outbound = _parse_field("outbound_detectors", OUTBOUND_DETECTOR_NAMES)
|
||||
inbound = _parse_field("inbound_detectors", INBOUND_DETECTOR_NAMES)
|
||||
|
||||
on_match = ""
|
||||
on_match_raw = d.get("outbound_on_match")
|
||||
if on_match_raw is not None:
|
||||
if not isinstance(on_match_raw, str) or on_match_raw not in OUTBOUND_ON_MATCH_VALUES:
|
||||
raise ManifestError(
|
||||
f"{label} outbound_on_match must be one of "
|
||||
f"{', '.join(OUTBOUND_ON_MATCH_VALUES)} (got {on_match_raw!r})"
|
||||
)
|
||||
on_match = on_match_raw
|
||||
|
||||
for k in d:
|
||||
if k not in ("outbound_detectors", "inbound_detectors"):
|
||||
if k not in ("outbound_detectors", "inbound_detectors", "outbound_on_match"):
|
||||
raise ManifestError(
|
||||
f"{label} has unknown key {k!r}; accepted keys are "
|
||||
f"'outbound_detectors', 'inbound_detectors'"
|
||||
f"'outbound_detectors', 'inbound_detectors', "
|
||||
f"'outbound_on_match'"
|
||||
)
|
||||
return outbound, inbound
|
||||
return outbound, inbound, on_match
|
||||
|
||||
|
||||
LOG_LEVELS = frozenset({0, 1, 2})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EgressConfig:
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
class ManifestEgressConfig:
|
||||
routes: tuple[ManifestEgressRoute, ...] = ()
|
||||
Log: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestEgressConfig":
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' egress")
|
||||
routes_raw = d.get("routes")
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
routes: tuple[ManifestEgressRoute, ...] = ()
|
||||
if routes_raw is not None:
|
||||
if not isinstance(routes_raw, list):
|
||||
raise ManifestError(
|
||||
@@ -363,14 +405,20 @@ class EgressConfig:
|
||||
)
|
||||
routes_list = cast(list[object], routes_raw)
|
||||
routes = tuple(
|
||||
EgressRoute.from_dict(bottle_name, i, entry)
|
||||
ManifestEgressRoute.from_dict(bottle_name, i, entry)
|
||||
for i, entry in enumerate(routes_list)
|
||||
)
|
||||
validate_egress_routes(bottle_name, routes)
|
||||
log_raw = d.get("log", 0)
|
||||
if isinstance(log_raw, bool) or not isinstance(log_raw, int) \
|
||||
or log_raw not in LOG_LEVELS:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' egress.log must be 0, 1, or 2"
|
||||
)
|
||||
for k in d:
|
||||
if k != "routes":
|
||||
if k not in ("routes", "log"):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' egress has unknown key {k!r}; "
|
||||
f"only 'routes' is accepted"
|
||||
f"accepted keys are 'routes', 'log'"
|
||||
)
|
||||
return cls(routes=routes)
|
||||
return cls(routes=routes, Log=log_raw)
|
||||
|
||||
+106
-36
@@ -5,25 +5,31 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import Bottle, GitEntry
|
||||
from .manifest import ManifestBottle
|
||||
from .manifest_egress import ManifestEgressConfig
|
||||
|
||||
|
||||
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
|
||||
"""Apply `extends:` chains and return resolved Bottle objects."""
|
||||
cache: dict[str, Bottle] = {}
|
||||
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
|
||||
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
|
||||
cache: dict[str, ManifestBottle] = {}
|
||||
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
|
||||
# Threaded alongside `cache` so a child can field-merge against its
|
||||
# parent's repos without reconstructing them from parsed entries.
|
||||
repos_cache: dict[str, dict[str, object]] = {}
|
||||
for name in raws:
|
||||
if name not in cache:
|
||||
_resolve_one_bottle(name, raws, cache, ())
|
||||
_resolve_one_bottle(name, raws, cache, repos_cache, ())
|
||||
return cache
|
||||
|
||||
|
||||
def _resolve_one_bottle(
|
||||
name: str,
|
||||
raws: dict[str, dict[str, object]],
|
||||
cache: dict[str, Bottle],
|
||||
cache: dict[str, ManifestBottle],
|
||||
repos_cache: dict[str, dict[str, object]],
|
||||
seen: tuple[str, ...],
|
||||
) -> Bottle:
|
||||
from .manifest import Bottle, ManifestError
|
||||
) -> ManifestBottle:
|
||||
from .manifest import ManifestBottle, ManifestError
|
||||
|
||||
if name in cache:
|
||||
return cache[name]
|
||||
@@ -32,14 +38,15 @@ def _resolve_one_bottle(
|
||||
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
|
||||
# Strip `extends:` before passing to ManifestBottle.from_dict so it
|
||||
# is not accidentally treated as a real ManifestBottle 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)
|
||||
bottle = ManifestBottle.from_dict(name, child_raw)
|
||||
cache[name] = bottle
|
||||
repos_cache[name] = _resolve_repos_raw({}, child_raw)
|
||||
return bottle
|
||||
|
||||
if not isinstance(parent_name_raw, str):
|
||||
@@ -59,49 +66,69 @@ def _resolve_one_bottle(
|
||||
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)
|
||||
parent = _resolve_one_bottle(
|
||||
parent_name, raws, cache, repos_cache, seen + (name,)
|
||||
)
|
||||
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw)
|
||||
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name)
|
||||
cache[name] = bottle
|
||||
repos_cache[name] = merged_repos_raw
|
||||
return bottle
|
||||
|
||||
|
||||
def _merge_bottles(
|
||||
parent: Bottle,
|
||||
parent: ManifestBottle,
|
||||
child_raw: dict[str, object],
|
||||
merged_repos_raw: dict[str, object],
|
||||
name: str,
|
||||
) -> Bottle:
|
||||
) -> ManifestBottle:
|
||||
"""Apply PRD 0025 merge rules."""
|
||||
from .manifest import Bottle, GitUser
|
||||
from .manifest import ManifestBottle, ManifestGitUser
|
||||
from .manifest_egress import validate_egress_routes
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
# Parse the child's declared fields into a Bottle (with the
|
||||
# git-gate.repos: when the child declares repos, inject the already
|
||||
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
||||
# parses with the full inherited+overridden list (issue #237).
|
||||
if _child_declares_git_gate_repos(child_raw):
|
||||
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
|
||||
|
||||
# Parse the child's declared fields into a ManifestBottle (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)
|
||||
child = ManifestBottle.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()
|
||||
# wins; empties fall through to parent. The default ManifestGitUser()
|
||||
# is two empty strings, so a child that omits git-gate.user
|
||||
# inherits the parent's user verbatim.
|
||||
merged_git_user = GitUser(
|
||||
merged_git_user = ManifestGitUser(
|
||||
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.
|
||||
# git-gate.repos: when declared, child.git already holds the merged
|
||||
# set (an explicit empty dict clears parent, leaving child.git empty).
|
||||
# When omitted, the parent's entries are inherited verbatim.
|
||||
if _child_declares_git_gate_repos(child_raw):
|
||||
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
|
||||
merged_git = child.git
|
||||
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
|
||||
# egress.routes: missing means inherit; otherwise parent and child
|
||||
# route lists concatenate. Other egress scalar fields remain
|
||||
# presence-driven overlays.
|
||||
merged_egress = (
|
||||
_merge_egress(parent.egress, child.egress, child_raw)
|
||||
if "egress" in child_raw
|
||||
else parent.egress
|
||||
)
|
||||
|
||||
# Presence-driven full-replace for the remaining scalar fields.
|
||||
merged_agent_provider = (
|
||||
child.agent_provider
|
||||
if "agent_provider" in child_raw
|
||||
@@ -112,7 +139,7 @@ def _merge_bottles(
|
||||
)
|
||||
validate_egress_routes(name, merged_egress.routes)
|
||||
|
||||
return Bottle(
|
||||
return ManifestBottle(
|
||||
env=merged_env,
|
||||
agent_provider=merged_agent_provider,
|
||||
git=merged_git,
|
||||
@@ -122,6 +149,45 @@ def _merge_bottles(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_repos_raw(
|
||||
parent_repos: dict[str, object],
|
||||
child_raw: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Compute a bottle's effective git-gate.repos as raw dicts.
|
||||
|
||||
Repos are keyed by name. When the child omits git-gate.repos it
|
||||
inherits the parent's set verbatim; an explicit empty dict clears it.
|
||||
Otherwise parent and child unite by name, with same-name entries
|
||||
field-merged (parent fields are defaults, child fields win)."""
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
if not _child_declares_git_gate_repos(child_raw):
|
||||
return parent_repos
|
||||
child_repos = _declared_repos_raw(child_raw)
|
||||
if not child_repos:
|
||||
return {}
|
||||
# Parent entries keep their order; child-only names are appended.
|
||||
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
|
||||
return {
|
||||
name: {
|
||||
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
|
||||
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
|
||||
}
|
||||
for name in names
|
||||
}
|
||||
|
||||
|
||||
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
||||
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
||||
or an empty dict when none are declared."""
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
if not _child_declares_git_gate_repos(child_raw):
|
||||
return {}
|
||||
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
|
||||
|
||||
|
||||
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
@@ -132,11 +198,15 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||
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())
|
||||
def _merge_egress(
|
||||
parent: ManifestEgressConfig,
|
||||
child: ManifestEgressConfig,
|
||||
child_raw: dict[str, object],
|
||||
) -> ManifestEgressConfig:
|
||||
from .manifest_egress import ManifestEgressConfig
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
|
||||
routes = parent.routes + child.routes
|
||||
log = child.Log if "log" in child_egress_raw else parent.Log
|
||||
return ManifestEgressConfig(routes=routes, Log=log)
|
||||
|
||||
+83
-76
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .manifest_util import ManifestError, as_json_object
|
||||
|
||||
@@ -13,6 +12,8 @@ from .manifest_util import ManifestError, as_json_object
|
||||
# defence; this regex is belt-and-suspenders and documents intent).
|
||||
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
_KEY_PROVIDERS = {"static", "gitea"}
|
||||
|
||||
|
||||
def _opt_str(value: object, label: str) -> str:
|
||||
if value is None:
|
||||
@@ -57,7 +58,7 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
||||
return (user, host, port, path)
|
||||
|
||||
|
||||
def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
||||
def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...]) -> None:
|
||||
seen: dict[str, None] = {}
|
||||
for g in git:
|
||||
if g.Name in seen:
|
||||
@@ -69,25 +70,27 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvisionedKeyConfig:
|
||||
"""Configuration for automatic deploy-key lifecycle management
|
||||
(PRD 0048). Used when a git-gate.repos entry opts out of a
|
||||
static identity file and instead wants a fresh SSH keypair
|
||||
generated at spin-up and revoked at teardown.
|
||||
class ManifestKeyConfig:
|
||||
"""Configuration for a repo's SSH key in git-gate.repos.
|
||||
|
||||
`provider` names the contrib sub-package to load (e.g. `gitea`).
|
||||
`token_env` is the name of a host-side env var carrying the API
|
||||
token; the value is read at provision time, never stored on the
|
||||
plan. `api_url` is the forge's HTTP API root; if empty, it is
|
||||
derived from the upstream URL's host at provision time."""
|
||||
`provider` is either `"static"` (a pre-existing key on the host) or
|
||||
`"gitea"` (automatic deploy-key lifecycle via the Gitea API).
|
||||
|
||||
For `static`: `path` is the host-side absolute path to the SSH private key.
|
||||
|
||||
For `gitea`: `forge_token_env` is the name of a host-side env var
|
||||
carrying the Gitea API token; the value is read at provision time,
|
||||
never stored on the plan. `api_url` is the forge's HTTP API root; if
|
||||
empty, it is derived from the upstream URL's host at provision time."""
|
||||
|
||||
provider: str
|
||||
token_env: str
|
||||
path: str = ""
|
||||
forge_token_env: str = ""
|
||||
api_url: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitEntry:
|
||||
class ManifestGitEntry:
|
||||
"""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
|
||||
@@ -99,15 +102,16 @@ class GitEntry:
|
||||
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/0048). Exactly
|
||||
one of `identity` (static key path) or `provisioned_key` (automatic
|
||||
lifecycle) must be present. The internal field names are stable."""
|
||||
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). A `key`
|
||||
block is required; `key.provider` is `"static"` or `"gitea"`. For
|
||||
`static`, `IdentityFile` is populated at parse time from `key.path`.
|
||||
For `gitea`, `IdentityFile` is populated at provision time."""
|
||||
|
||||
Name: str
|
||||
Upstream: str
|
||||
Key: ManifestKeyConfig = ManifestKeyConfig(provider="")
|
||||
IdentityFile: str = ""
|
||||
KnownHostKey: str = ""
|
||||
ProvisionedKey: Optional[ProvisionedKeyConfig] = None
|
||||
RemoteKey: str = ""
|
||||
UpstreamUser: str = ""
|
||||
UpstreamHost: str = ""
|
||||
@@ -117,11 +121,11 @@ class GitEntry:
|
||||
@classmethod
|
||||
def from_repos_entry(
|
||||
cls, bottle_name: str, repo_name: str, raw: object
|
||||
) -> "GitEntry":
|
||||
) -> "ManifestGitEntry":
|
||||
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
||||
|
||||
YAML keys: `url` (required), exactly one of `identity` or
|
||||
`provisioned_key` (required), `host_key` (optional).
|
||||
YAML keys: `url` (required), `key` (required object with
|
||||
`provider`, and provider-specific fields), `host_key` (optional).
|
||||
The repo_name becomes `Name`."""
|
||||
if not repo_name:
|
||||
raise ManifestError(
|
||||
@@ -135,10 +139,10 @@ class GitEntry:
|
||||
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", "provisioned_key", "host_key"}:
|
||||
if k not in {"url", "key", "host_key"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
||||
f"allowed: url, identity, provisioned_key, host_key"
|
||||
f"allowed: url, key, host_key"
|
||||
)
|
||||
upstream = d.get("url")
|
||||
if not isinstance(upstream, str) or not upstream:
|
||||
@@ -146,32 +150,13 @@ class GitEntry:
|
||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
||||
)
|
||||
|
||||
has_identity = "identity" in d
|
||||
has_provisioned = "provisioned_key" in d
|
||||
if has_identity and has_provisioned:
|
||||
if "key" not in d:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} must set exactly one of "
|
||||
f"'identity' or 'provisioned_key'; got both."
|
||||
)
|
||||
if not has_identity and not has_provisioned:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} must set exactly one of "
|
||||
f"'identity' or 'provisioned_key'; got neither."
|
||||
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
||||
)
|
||||
key_config = _parse_key_config(bottle_name, label, d["key"])
|
||||
|
||||
ident = ""
|
||||
provisioned_key: Optional[ProvisionedKeyConfig] = None
|
||||
if has_identity:
|
||||
raw_ident = d.get("identity")
|
||||
if not isinstance(raw_ident, str) or not raw_ident:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
|
||||
)
|
||||
ident = raw_ident
|
||||
else:
|
||||
provisioned_key = _parse_provisioned_key_config(
|
||||
bottle_name, label, d["provisioned_key"]
|
||||
)
|
||||
ident = key_config.path if key_config.provider == "static" else ""
|
||||
|
||||
khk = _opt_str(
|
||||
d.get("host_key"),
|
||||
@@ -183,9 +168,9 @@ class GitEntry:
|
||||
return cls(
|
||||
Name=repo_name,
|
||||
Upstream=upstream,
|
||||
Key=key_config,
|
||||
IdentityFile=ident,
|
||||
KnownHostKey=khk,
|
||||
ProvisionedKey=provisioned_key,
|
||||
RemoteKey=host,
|
||||
UpstreamUser=user,
|
||||
UpstreamHost=host,
|
||||
@@ -194,42 +179,64 @@ class GitEntry:
|
||||
)
|
||||
|
||||
|
||||
def _parse_provisioned_key_config(
|
||||
def _parse_key_config(
|
||||
bottle_name: str, label: str, raw: object
|
||||
) -> ProvisionedKeyConfig:
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
|
||||
for k in d:
|
||||
if k not in {"provider", "token_env", "api_url"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
|
||||
f"allowed: provider, token_env, api_url"
|
||||
)
|
||||
) -> ManifestKeyConfig:
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.key")
|
||||
provider = d.get("provider")
|
||||
if not isinstance(provider, str) or not provider:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||
f"bottle '{bottle_name}' {label}.key missing required "
|
||||
f"string field 'provider'"
|
||||
)
|
||||
token_env = d.get("token_env")
|
||||
if not isinstance(token_env, str) or not token_env:
|
||||
if provider not in _KEY_PROVIDERS:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||
f"string field 'token_env'"
|
||||
f"bottle '{bottle_name}' {label}.key provider {provider!r} is unknown; "
|
||||
f"allowed: {', '.join(sorted(_KEY_PROVIDERS))}"
|
||||
)
|
||||
api_url_raw = d.get("api_url", "")
|
||||
if not isinstance(api_url_raw, str):
|
||||
|
||||
if provider == "gitea":
|
||||
for k in d:
|
||||
if k not in {"provider", "forge_token_env", "api_url"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
|
||||
f"for provider 'gitea'; allowed: provider, forge_token_env, api_url"
|
||||
)
|
||||
forge_token_env = d.get("forge_token_env")
|
||||
if not isinstance(forge_token_env, str) or not forge_token_env:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.key missing required "
|
||||
f"string field 'forge_token_env' for provider 'gitea'"
|
||||
)
|
||||
api_url_raw = d.get("api_url", "")
|
||||
if not isinstance(api_url_raw, str):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.key 'api_url' must be a string"
|
||||
)
|
||||
return ManifestKeyConfig(
|
||||
provider=provider,
|
||||
forge_token_env=forge_token_env,
|
||||
api_url=api_url_raw,
|
||||
)
|
||||
|
||||
# provider == "static"
|
||||
for k in d:
|
||||
if k not in {"provider", "path"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
|
||||
f"for provider 'static'; allowed: provider, path"
|
||||
)
|
||||
path = d.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
|
||||
f"bottle '{bottle_name}' {label}.key missing required "
|
||||
f"string field 'path' for provider 'static'"
|
||||
)
|
||||
return ProvisionedKeyConfig(
|
||||
provider=provider,
|
||||
token_env=token_env,
|
||||
api_url=api_url_raw,
|
||||
)
|
||||
return ManifestKeyConfig(provider=provider, path=path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitUser:
|
||||
class ManifestGitUser:
|
||||
"""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
|
||||
@@ -244,7 +251,7 @@ class GitUser:
|
||||
email: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestGitUser":
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
|
||||
for k in d:
|
||||
if k not in {"name", "email"}:
|
||||
@@ -279,7 +286,7 @@ class GitUser:
|
||||
def parse_git_gate_config(
|
||||
bottle_name: str,
|
||||
raw: object,
|
||||
) -> tuple[tuple[GitEntry, ...], GitUser]:
|
||||
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
||||
for k in d:
|
||||
if k not in {"user", "repos"}:
|
||||
@@ -289,17 +296,17 @@ def parse_git_gate_config(
|
||||
)
|
||||
|
||||
git_user = (
|
||||
GitUser.from_dict(bottle_name, d["user"])
|
||||
ManifestGitUser.from_dict(bottle_name, d["user"])
|
||||
if "user" in d
|
||||
else GitUser()
|
||||
else ManifestGitUser()
|
||||
)
|
||||
|
||||
git: tuple[GitEntry, ...] = ()
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
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)
|
||||
ManifestGitEntry.from_repos_entry(bottle_name, name, entry)
|
||||
for name, entry in repos.items()
|
||||
)
|
||||
validate_unique_git_names(bottle_name, git)
|
||||
|
||||
@@ -8,21 +8,19 @@ 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 .manifest_util import ManifestError
|
||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import Agent, Bottle
|
||||
from .manifest import ManifestBottle
|
||||
|
||||
|
||||
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(
|
||||
@@ -34,48 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
|
||||
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
|
||||
|
||||
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}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{path}: {e}") from 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, # noqa: F841 — unused, but required by interface
|
||||
) -> 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] = {}
|
||||
No file content is read. Invalid filenames are skipped with a warning."""
|
||||
result: dict[str, Path] = {}
|
||||
if not agents_dir.is_dir():
|
||||
return out
|
||||
return result
|
||||
for path in sorted(agents_dir.glob("*.md")):
|
||||
name = entity_name_from_path(path)
|
||||
if name is None:
|
||||
@@ -84,22 +47,45 @@ def load_agents_from_dir(
|
||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||
)
|
||||
continue
|
||||
result[name] = path
|
||||
return result
|
||||
|
||||
|
||||
def load_bottle_chain_from_dir(
|
||||
bottle_name: str, bottles_dir: Path
|
||||
) -> ManifestBottle:
|
||||
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
|
||||
returning the resolved ManifestBottle.
|
||||
|
||||
Only the files in the extends chain are read — unrelated bottle files
|
||||
are never touched. Raises ManifestError on parse or validation failure."""
|
||||
from .manifest_extends import resolve_bottles
|
||||
|
||||
raws: dict[str, dict[str, object]] = {}
|
||||
to_load = [bottle_name]
|
||||
while to_load:
|
||||
name = to_load.pop()
|
||||
if name in raws:
|
||||
continue
|
||||
path = bottles_dir / f"{name}.md"
|
||||
if not path.is_file():
|
||||
avail = ", ".join(
|
||||
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
|
||||
) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' not found at {path}. "
|
||||
f"Available: {avail}"
|
||||
)
|
||||
try:
|
||||
fm, body = parse_frontmatter(path.read_text())
|
||||
fm, _body = parse_frontmatter(path.read_text())
|
||||
except OSError as e:
|
||||
raise ManifestError(f"could not read {path}: {e}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{path}: {e}") from 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
|
||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||
raws[name] = dict(fm)
|
||||
parent = fm.get("extends")
|
||||
if isinstance(parent, str):
|
||||
to_load.append(parent)
|
||||
|
||||
return resolve_bottles(raws)[bottle_name]
|
||||
|
||||
@@ -59,6 +59,7 @@ class _DaemonSpec:
|
||||
# reads to inject `Authorization` headers on configured routes;
|
||||
# no other daemon in the bundle should see these values.
|
||||
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
|
||||
_READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
|
||||
|
||||
|
||||
def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
@@ -82,6 +83,22 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _argv_for_daemon(name: str, argv: Sequence[str], env: dict[str, str]) -> list[str]:
|
||||
ready_file = env.get("BOT_BOTTLE_GIT_GATE_READY_FILE", "").strip()
|
||||
if name not in _READY_GATED_DAEMONS or not ready_file:
|
||||
return list(argv)
|
||||
return [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"while [ ! -f \"$BOT_BOTTLE_GIT_GATE_READY_FILE\" ]; do "
|
||||
"sleep 0.1; "
|
||||
"done; "
|
||||
"exec \"$@\"",
|
||||
name,
|
||||
*argv,
|
||||
]
|
||||
|
||||
|
||||
def _selected_daemons(
|
||||
env: dict[str, str],
|
||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||
@@ -118,12 +135,13 @@ def _pump(name: str, stream: IO[bytes]) -> None:
|
||||
|
||||
|
||||
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
|
||||
env = _env_for_daemon(spec.name, dict(os.environ))
|
||||
proc = subprocess.Popen(
|
||||
list(spec.argv),
|
||||
_argv_for_daemon(spec.name, spec.argv, env),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
env=_env_for_daemon(spec.name, dict(os.environ)),
|
||||
env=env,
|
||||
)
|
||||
threading.Thread(
|
||||
target=_pump, args=(spec.name, proc.stdout), daemon=True
|
||||
|
||||
+25
-18
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (bot_bottle.supervise_server)
|
||||
sits on the bottle's internal network and exposes three MCP tools the
|
||||
agent calls when it hits a stuck-recovery category:
|
||||
|
||||
* egress-block — agent proposes a new routes.yaml
|
||||
* egress-block / allow — agent proposes a new routes.yaml
|
||||
* capability-block — agent proposes a new agent Dockerfile
|
||||
|
||||
Each tool call: the agent passes the full proposed file plus a
|
||||
@@ -48,30 +48,39 @@ from pathlib import Path
|
||||
SUPERVISE_HOSTNAME = "supervise"
|
||||
SUPERVISE_PORT = 9100
|
||||
|
||||
TOOL_EGRESS_BLOCK = "egress-block"
|
||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||
TOOL_EGRESS_BLOCK = "egress-block"
|
||||
TOOL_EGRESS_ALLOW = "egress-allow"
|
||||
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||
# Written directly by the egress addon (not an agent-facing MCP tool) when an
|
||||
# outbound DLP token block is routed to the operator for override (PRD 0062).
|
||||
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||
TOOLS: tuple[str, ...] = (
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
|
||||
# The supervise sidecar uses these to query egress's
|
||||
# introspection endpoint for the `list-egress-routes` MCP
|
||||
# tool. The hostname + port match egress's docker network
|
||||
# alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
|
||||
# and backend.docker.egress.EGRESS_PORT — the values
|
||||
# are inlined here so the in-container supervise_server doesn't
|
||||
# need to import the egress package).
|
||||
EGRESS_FORWARD_PROXY = "http://egress:9099"
|
||||
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
|
||||
# daemon runs inside the sidecar bundle alongside egress, so loopback
|
||||
# is the stable address across docker, smolmachines, and Apple
|
||||
# Container backends.
|
||||
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
||||
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
||||
|
||||
# capability-block has no on-disk config the operator edits in place
|
||||
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
||||
# here — those changes are captured by git history + the rebuild
|
||||
# record laid down in PRD 0016.
|
||||
# here — those changes are captured by git history + the rebuild record
|
||||
# laid down in PRD 0016.
|
||||
COMPONENT_FOR_TOOL: dict[str, str] = {
|
||||
TOOL_EGRESS_ALLOW: "egress",
|
||||
TOOL_EGRESS_BLOCK: "egress",
|
||||
}
|
||||
|
||||
@@ -435,9 +444,9 @@ def sha256_hex(content: str) -> str:
|
||||
# Dockerfile and propose modifications.
|
||||
#
|
||||
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
|
||||
# moved them behind the `list-egress-routes` MCP tool (live
|
||||
# state from egress's introspection endpoint) so the agent
|
||||
# always sees current data rather than a launch-time snapshot.
|
||||
# moved them behind the `list-egress-routes` MCP tool (live state
|
||||
# from egress's introspection endpoint) so the agent always sees
|
||||
# current data rather than a launch-time snapshot.
|
||||
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
|
||||
|
||||
|
||||
@@ -469,8 +478,6 @@ class Supervise(ABC):
|
||||
self,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
*,
|
||||
dockerfile_content: str = "",
|
||||
) -> SupervisePlan:
|
||||
"""Stage the per-bottle queue dir on the host and the
|
||||
current-config dir under `stage_dir`. Returns the plan;
|
||||
@@ -480,9 +487,6 @@ class Supervise(ABC):
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
current_config_dir = stage_dir / "current-config"
|
||||
current_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
|
||||
dockerfile_path.write_text(dockerfile_content)
|
||||
dockerfile_path.chmod(0o644)
|
||||
return SupervisePlan(
|
||||
slug=slug,
|
||||
queue_dir=queue_dir,
|
||||
@@ -555,7 +559,10 @@ __all__ = [
|
||||
"EGRESS_FORWARD_PROXY",
|
||||
"EGRESS_INTROSPECT_URL",
|
||||
"TOOL_CAPABILITY_BLOCK",
|
||||
"TOOL_EGRESS_ALLOW",
|
||||
"TOOL_EGRESS_BLOCK",
|
||||
"TOOL_GITLEAKS_ALLOW",
|
||||
"TOOL_EGRESS_TOKEN_ALLOW",
|
||||
"TOOL_LIST_EGRESS_ROUTES",
|
||||
"archive_proposal",
|
||||
"audit_dir",
|
||||
|
||||
+119
-150
@@ -1,8 +1,10 @@
|
||||
"""Supervise sidecar HTTP server (PRD 0013).
|
||||
|
||||
Per-bottle MCP server exposing two tools — `egress-block`,
|
||||
`capability-block` — that the agent calls to propose config changes
|
||||
when stuck. Each tool call:
|
||||
Per-bottle MCP server exposing tools the agent calls to propose config
|
||||
changes when stuck. The tools are `allow`, `egress-block`,
|
||||
`capability-block`, and `list-egress-routes`.
|
||||
|
||||
Each queued tool call:
|
||||
|
||||
1. Validates the proposed file syntactically.
|
||||
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
|
||||
@@ -42,9 +44,15 @@ import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Same-directory import inside the bundle container; `supervise.py`
|
||||
# is COPYed alongside this file by Dockerfile.sidecars.
|
||||
import supervise as _sv
|
||||
try:
|
||||
# Same-directory imports inside the bundle container; these files are
|
||||
# COPYed flat under /app by Dockerfile.sidecars.
|
||||
from egress_addon_core import LOG_OFF, load_config
|
||||
import supervise as _sv
|
||||
except ModuleNotFoundError:
|
||||
# Package imports for host-side tests and tooling.
|
||||
from .egress_addon_core import LOG_OFF, load_config
|
||||
from . import supervise as _sv
|
||||
|
||||
|
||||
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
||||
@@ -133,69 +141,6 @@ def jsonrpc_error(request_id: object, code: int, message: str) -> bytes:
|
||||
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_BLOCK,
|
||||
"description": (
|
||||
"Call when egress refused your HTTPS request — host "
|
||||
"without a matching route, or a request that did not match "
|
||||
"the route's matches rules (typically a 403 from the "
|
||||
"proxy). Propose a SINGLE route to add: the host you "
|
||||
"need + (optionally) a path_allowlist of path prefixes + "
|
||||
"(optionally) an auth block. The supervisor merges the "
|
||||
"route into the live table at approval time — you do NOT "
|
||||
"need to see or reproduce the existing routes. If the "
|
||||
"host already has a route, the proposed paths are unioned "
|
||||
"with the existing ones (host stays single-route). The "
|
||||
"operator approves or rejects in the supervise TUI. On "
|
||||
"approval the supervisor writes the merged routes.yaml "
|
||||
"and SIGHUPs egress (no dropped connections)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The hostname to allow (e.g. 'api.github.com'). "
|
||||
"Case-insensitive on match."
|
||||
),
|
||||
},
|
||||
"path_allowlist": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Optional URL path prefixes the route permits. "
|
||||
"Each must start with '/'. Omit to allow all "
|
||||
"paths under this host (bare-pass route). "
|
||||
"Internally converted to matches entries."
|
||||
),
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Optional credential injection. {scheme, "
|
||||
"token_ref}: scheme is 'Bearer' or 'token'; "
|
||||
"token_ref names the host env var holding the "
|
||||
"secret value. Omit to add a host without "
|
||||
"credential injection. Ignored if the host "
|
||||
"already has a route (operator decides auth "
|
||||
"changes, not the agent)."
|
||||
),
|
||||
"properties": {
|
||||
"scheme": {"type": "string"},
|
||||
"token_ref": {"type": "string"},
|
||||
},
|
||||
"required": ["scheme", "token_ref"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Why this host needs to be allowed.",
|
||||
},
|
||||
},
|
||||
"required": ["host", "justification"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": _sv.TOOL_LIST_EGRESS_ROUTES,
|
||||
"description": (
|
||||
@@ -203,8 +148,9 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
"allowlist. Returns JSON with one entry per allowed host, "
|
||||
"each carrying its matches rules (if any) and whether "
|
||||
"the proxy injects Authorization for the route. Use this "
|
||||
"before composing an `egress-block` proposal so the new "
|
||||
"routes file extends the live one rather than replacing it."
|
||||
"before composing an `egress-allow` or `egress-block` proposal so "
|
||||
"the new routes file extends the live one rather than "
|
||||
"replacing it."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -212,6 +158,90 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||
"description": (
|
||||
"Request operator approval to change the bottle's egress "
|
||||
"allowlist. Pass the full proposed routes.yaml content, not "
|
||||
"just the new host, plus a justification. Use "
|
||||
"`list-egress-routes` first so the proposal preserves existing "
|
||||
"routes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"routes_yaml": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full proposed /etc/egress/routes.yaml content. "
|
||||
"Each route entry accepts these keys:\n"
|
||||
" host: <hostname> (required)\n"
|
||||
" auth_scheme: Bearer|token (must pair with token_env)\n"
|
||||
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
|
||||
" matches: (optional list of match entries)\n"
|
||||
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
|
||||
" methods: [GET, POST, ...]\n"
|
||||
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
|
||||
" git: (optional; omit to block git clone/fetch)\n"
|
||||
" fetch: true\n"
|
||||
" dlp: (optional DLP scanner overrides)\n"
|
||||
" outbound_detectors: [token_patterns, known_secrets]\n"
|
||||
" inbound_detectors: [naive_injection_detection]\n"
|
||||
" outbound_on_match: block|redact|supervise (default supervise)\n"
|
||||
"Omit any key that should use its default. "
|
||||
"`list-egress-routes` returns routes in this same format."
|
||||
),
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Why this egress route is needed.",
|
||||
},
|
||||
},
|
||||
"required": ["routes_yaml", "justification"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_BLOCK,
|
||||
"description": (
|
||||
"Request operator approval to change the bottle's egress "
|
||||
"allowlist after a blocked outbound request. Pass the full "
|
||||
"proposed routes.yaml content plus a justification. Use "
|
||||
"`list-egress-routes` first so the proposal preserves existing "
|
||||
"routes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"routes_yaml": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full proposed /etc/egress/routes.yaml content. "
|
||||
"Each route entry accepts these keys:\n"
|
||||
" host: <hostname> (required)\n"
|
||||
" auth_scheme: Bearer|token (must pair with token_env)\n"
|
||||
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
|
||||
" matches: (optional list of match entries)\n"
|
||||
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
|
||||
" methods: [GET, POST, ...]\n"
|
||||
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
|
||||
" git: (optional; omit to block git clone/fetch)\n"
|
||||
" fetch: true\n"
|
||||
" dlp: (optional DLP scanner overrides)\n"
|
||||
" outbound_detectors: [token_patterns, known_secrets]\n"
|
||||
" inbound_detectors: [naive_injection_detection]\n"
|
||||
" outbound_on_match: block|redact|supervise (default supervise)\n"
|
||||
"Omit any key that should use its default. "
|
||||
"`list-egress-routes` returns routes in this same format."
|
||||
),
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Why this egress route is needed.",
|
||||
},
|
||||
},
|
||||
"required": ["routes_yaml", "justification"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
||||
"description": (
|
||||
@@ -243,22 +273,18 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
]
|
||||
|
||||
|
||||
# Map each non-egress tool to the input field that carries the agent's
|
||||
# payload (stored in Proposal.proposed_file). egress-block builds its
|
||||
# payload from structured input fields in `handle_egress_block`.
|
||||
# Map each proposal tool to the input field that carries the agent's
|
||||
# payload (stored in Proposal.proposed_file).
|
||||
PROPOSED_FILE_FIELD: dict[str, str] = {
|
||||
_sv.TOOL_EGRESS_ALLOW: "routes_yaml",
|
||||
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
||||
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
|
||||
}
|
||||
|
||||
|
||||
# --- Validation ------------------------------------------------------------
|
||||
|
||||
|
||||
# Auth schemes accepted on egress-block proposals — match the
|
||||
# manifest-side EGRESS_AUTH_SCHEMES.
|
||||
_AUTH_SCHEMES = ("Bearer", "token")
|
||||
|
||||
|
||||
def validate_proposed_file(tool: str, content: str) -> None:
|
||||
"""Syntactic validation. The operator is the real gate; this just
|
||||
catches obvious paste-errors / wrong-tool selections before they
|
||||
@@ -269,74 +295,23 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
||||
# Dockerfiles are too varied to validate syntactically beyond
|
||||
# non-empty. The operator reads the diff in the TUI.
|
||||
pass
|
||||
elif tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
||||
try:
|
||||
config = load_config(content)
|
||||
except ValueError as e:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.yaml is not valid: {e}",
|
||||
) from e
|
||||
if config.log != LOG_OFF:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.yaml must not change egress logging",
|
||||
)
|
||||
else:
|
||||
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
|
||||
|
||||
|
||||
def _validate_and_bundle_egress_route(
|
||||
args: dict[str, object],
|
||||
) -> str:
|
||||
"""Validate egress-block input fields and bundle them into
|
||||
a JSON string that becomes the Proposal.proposed_file. Raises
|
||||
_RpcError on bad input — the agent retries with a fixed shape."""
|
||||
tool = _sv.TOOL_EGRESS_BLOCK
|
||||
host = args.get("host")
|
||||
if not isinstance(host, str) or not host.strip():
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: 'host' is required and must be a non-empty string",
|
||||
)
|
||||
payload: dict[str, object] = {"host": host}
|
||||
|
||||
path_allow_raw = args.get("path_allowlist")
|
||||
if path_allow_raw is not None:
|
||||
if not isinstance(path_allow_raw, list):
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: 'path_allowlist' must be an array of strings",
|
||||
)
|
||||
prefixes: list[str] = []
|
||||
for i, p in enumerate(path_allow_raw):
|
||||
if not isinstance(p, str):
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: path_allowlist[{i}] must be a string",
|
||||
)
|
||||
if not p.startswith("/"):
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: path_allowlist[{i}] {p!r} must start with '/'",
|
||||
)
|
||||
prefixes.append(p)
|
||||
if prefixes:
|
||||
payload["path_allowlist"] = prefixes
|
||||
|
||||
auth_raw = args.get("auth")
|
||||
if auth_raw is not None:
|
||||
if not isinstance(auth_raw, dict):
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: 'auth' must be an object with 'scheme' and 'token_ref'",
|
||||
)
|
||||
scheme = auth_raw.get("scheme")
|
||||
token_ref = auth_raw.get("token_ref")
|
||||
if not isinstance(scheme, str) or scheme not in _AUTH_SCHEMES:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: auth.scheme must be one of "
|
||||
f"{', '.join(_AUTH_SCHEMES)} (got {scheme!r})",
|
||||
)
|
||||
if not isinstance(token_ref, str) or not token_ref:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: auth.token_ref must be a non-empty string "
|
||||
f"naming the host env var holding the token",
|
||||
)
|
||||
payload["auth"] = {"scheme": scheme, "token_ref": token_ref}
|
||||
|
||||
return json.dumps(payload, indent=2) + "\n"
|
||||
|
||||
|
||||
# --- MCP handlers ----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -422,13 +397,7 @@ def handle_tools_call(
|
||||
f"{name}: 'justification' is required and must be a non-empty string",
|
||||
)
|
||||
|
||||
if name == _sv.TOOL_EGRESS_BLOCK:
|
||||
# Structured input → JSON bundle on Proposal.proposed_file.
|
||||
# The dashboard's apply step (egress_apply.add_route)
|
||||
# parses this JSON, fetches the current routes, merges in
|
||||
# the new one, and writes the merged file.
|
||||
proposed_file = _validate_and_bundle_egress_route(args_raw)
|
||||
elif name in PROPOSED_FILE_FIELD:
|
||||
if name in PROPOSED_FILE_FIELD:
|
||||
file_field = PROPOSED_FILE_FIELD[name]
|
||||
proposed_file = args_raw.get(file_field)
|
||||
if not isinstance(proposed_file, str):
|
||||
|
||||
+30
-36
@@ -69,12 +69,6 @@ class YamlSubsetError(ValueError):
|
||||
egress sidecar's addon) handle it as a normal exception."""
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
"""Module-local helper so the parser body reads cleanly. Just
|
||||
raises YamlSubsetError — the `bot-bottle: error: ` prefix
|
||||
is added by the boundary `die` in `bot_bottle.log`."""
|
||||
raise YamlSubsetError(msg)
|
||||
|
||||
|
||||
# --- Tokenizer / line preprocessing ----------------------------------------
|
||||
|
||||
@@ -119,7 +113,7 @@ def _tokenize(text: str) -> list[_Line]:
|
||||
# editors render them differently and the spec says spaces.
|
||||
leading = len(raw) - len(raw.lstrip(" \t"))
|
||||
if "\t" in raw[:leading]:
|
||||
die(f"yaml-subset: tab character in indent on line {n}")
|
||||
raise YamlSubsetError(f"yaml-subset: tab character in indent on line {n}")
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
@@ -169,14 +163,14 @@ def _parse_scalar(s: str, lineno: int) -> object:
|
||||
s.startswith("'") and s.endswith("'")
|
||||
):
|
||||
if len(s) < 2:
|
||||
die(f"yaml-subset: unterminated quoted string on line {lineno}")
|
||||
raise YamlSubsetError(f"yaml-subset: unterminated quoted string on line {lineno}")
|
||||
body = s[1:-1]
|
||||
if s.startswith('"'):
|
||||
# JSON-style escapes for double quotes.
|
||||
try:
|
||||
return body.encode("utf-8").decode("unicode_escape")
|
||||
except UnicodeDecodeError as e:
|
||||
die(f"yaml-subset: bad escape on line {lineno}: {e}")
|
||||
raise YamlSubsetError(f"yaml-subset: bad escape on line {lineno}: {e}")
|
||||
else:
|
||||
# Single quotes: only '' → ' (standard YAML); no other escapes.
|
||||
return body.replace("''", "'")
|
||||
@@ -186,7 +180,7 @@ def _parse_scalar(s: str, lineno: int) -> object:
|
||||
if s in _RESERVED_BOOL_LIKE:
|
||||
if s in ("true", "false"):
|
||||
return s == "true"
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: bare {s!r} on line {lineno} is ambiguous "
|
||||
f"(use literal `true` / `false`, or quote it as a string)"
|
||||
)
|
||||
@@ -203,22 +197,22 @@ def _parse_scalar(s: str, lineno: int) -> object:
|
||||
|
||||
# Look-alikes that we reject to keep the user in control.
|
||||
if _DATE_RX.match(s):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: bare {s!r} on line {lineno} looks like a "
|
||||
f"date — quote it as a string or use an explicit int"
|
||||
)
|
||||
if _OCTAL_RX.match(s):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: bare {s!r} on line {lineno} looks like an "
|
||||
f"octal/0-prefixed integer — quote it as a string"
|
||||
)
|
||||
if _HEX_RX.match(s):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: bare {s!r} on line {lineno} looks like a "
|
||||
f"hex integer — quote it as a string"
|
||||
)
|
||||
if _FLOAT_RX.match(s):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: floats not supported (line {lineno}, "
|
||||
f"value {s!r}); use an int or quote as a string"
|
||||
)
|
||||
@@ -241,7 +235,7 @@ def _parse_inline(s: str, lineno: int) -> object:
|
||||
s = s.strip()
|
||||
if s.startswith("["):
|
||||
if not s.endswith("]"):
|
||||
die(f"yaml-subset: unterminated `[` on line {lineno}")
|
||||
raise YamlSubsetError(f"yaml-subset: unterminated `[` on line {lineno}")
|
||||
body = s[1:-1].strip()
|
||||
if not body:
|
||||
return []
|
||||
@@ -252,21 +246,21 @@ def _parse_inline(s: str, lineno: int) -> object:
|
||||
return items
|
||||
if s.startswith("{"):
|
||||
if not s.endswith("}"):
|
||||
die(f"yaml-subset: unterminated `{{` on line {lineno}")
|
||||
raise YamlSubsetError(f"yaml-subset: unterminated `{{` on line {lineno}")
|
||||
body = s[1:-1].strip()
|
||||
if not body:
|
||||
return {}
|
||||
out: dict[str, object] = {}
|
||||
for raw in _split_flow(body, lineno, "dict"):
|
||||
if ":" not in raw:
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: inline dict entry on line {lineno} "
|
||||
f"missing `:` ({raw!r})"
|
||||
)
|
||||
k, _, v = raw.partition(":")
|
||||
k = k.strip()
|
||||
if not _BARE_RX.match(k):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: inline dict key on line {lineno} "
|
||||
f"must be a bare identifier ({k!r})"
|
||||
)
|
||||
@@ -296,7 +290,7 @@ def _split_flow(body: str, lineno: int, kind: str) -> list[str]:
|
||||
elif ch in "]}":
|
||||
depth_b -= 1
|
||||
if depth_b > 0:
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: nested flow {kind} on line "
|
||||
f"{lineno} (only one level of flow allowed)"
|
||||
)
|
||||
@@ -330,7 +324,7 @@ def _split_key_value(content: str, lineno: int) -> tuple[str, str]:
|
||||
# ambiguous with URLs etc.).
|
||||
if i + 1 >= len(content) or content[i + 1] in (" ", "\t"):
|
||||
return content[:i].strip(), content[i + 1:].lstrip()
|
||||
die(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}")
|
||||
raise YamlSubsetError(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}")
|
||||
return "", "" # unreachable, but needed for type checker
|
||||
|
||||
|
||||
@@ -341,15 +335,15 @@ def _parse_block(
|
||||
to live at `base_indent`. Returns (value, new_idx) where
|
||||
`new_idx` is the index of the first unconsumed line."""
|
||||
if idx >= len(lines):
|
||||
die("yaml-subset: unexpected end of document")
|
||||
raise YamlSubsetError("yaml-subset: unexpected end of document")
|
||||
first = lines[idx]
|
||||
if first.indent < base_indent:
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {first.lineno} indented less than "
|
||||
f"expected (got {first.indent}, expected >= {base_indent})"
|
||||
)
|
||||
if first.indent > base_indent:
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {first.lineno} indented more than "
|
||||
f"expected (got {first.indent}, expected {base_indent})"
|
||||
)
|
||||
@@ -366,18 +360,18 @@ def _parse_block_mapping(
|
||||
while idx < len(lines) and lines[idx].indent == base_indent:
|
||||
line = lines[idx]
|
||||
if line.content.startswith("- "):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {line.lineno} unexpected list "
|
||||
f"item at mapping indent (got `-`, expected `key:`)"
|
||||
)
|
||||
key, value_text = _split_key_value(line.content, line.lineno)
|
||||
if not _BARE_RX.match(key):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {line.lineno} key {key!r} is not "
|
||||
f"a bare identifier"
|
||||
)
|
||||
if key in out:
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {line.lineno} duplicate key {key!r}"
|
||||
)
|
||||
if value_text:
|
||||
@@ -417,7 +411,7 @@ def _parse_block_list(
|
||||
content_col = base_indent + 2
|
||||
first_key, first_value_text = _split_key_value(rest, line.lineno)
|
||||
if not _BARE_RX.match(first_key):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {line.lineno} key {first_key!r} "
|
||||
f"is not a bare identifier"
|
||||
)
|
||||
@@ -440,12 +434,12 @@ def _parse_block_list(
|
||||
break # next list item, not a sibling key
|
||||
k, v_text = _split_key_value(ln.content, ln.lineno)
|
||||
if not _BARE_RX.match(k):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {ln.lineno} key {k!r} is "
|
||||
f"not a bare identifier"
|
||||
)
|
||||
if k in item:
|
||||
die(f"yaml-subset: line {ln.lineno} duplicate key {k!r}")
|
||||
raise YamlSubsetError(f"yaml-subset: line {ln.lineno} duplicate key {k!r}")
|
||||
if v_text:
|
||||
item[k] = _parse_inline(v_text, ln.lineno)
|
||||
idx += 1
|
||||
@@ -501,7 +495,7 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
|
||||
for n, raw in enumerate(text.splitlines(), start=1):
|
||||
s = raw.strip()
|
||||
if s.startswith("|") or s.startswith(">") or s.startswith("- |") or s.startswith("- >"):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {n} uses a multi-line block "
|
||||
f"scalar (`|` / `>`) — not supported. Use a quoted "
|
||||
f"single-line string instead."
|
||||
@@ -511,12 +505,12 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
|
||||
# not when it's inside a quoted string. Cheap check: any
|
||||
# bare `&foo:` / `*foo` at the start of a value position.
|
||||
if re.search(r"(^|\s)[&*][A-Za-z0-9_]+", s):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {n} uses anchors / aliases "
|
||||
f"(`&` / `*`) — not supported."
|
||||
)
|
||||
if "!!" in s and not (s.count("'") % 2 or s.count('"') % 2):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: line {n} uses a YAML tag (`!!`) — not "
|
||||
f"supported."
|
||||
)
|
||||
@@ -526,18 +520,18 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
|
||||
return {}
|
||||
base_indent = lines[0].indent
|
||||
if base_indent != 0:
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: top-level content must start in column 0 "
|
||||
f"(got column {base_indent} on line {lines[0].lineno})"
|
||||
)
|
||||
value, consumed = _parse_block(lines, 0, 0)
|
||||
if consumed < len(lines):
|
||||
die(
|
||||
raise YamlSubsetError(
|
||||
f"yaml-subset: trailing content starting on line "
|
||||
f"{lines[consumed].lineno}"
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
die("yaml-subset: top-level value must be a mapping")
|
||||
raise YamlSubsetError("yaml-subset: top-level value must be a mapping")
|
||||
return cast(dict[str, object], value)
|
||||
|
||||
|
||||
@@ -576,7 +570,7 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
||||
fm_end_lineno = line_idx
|
||||
break
|
||||
if body_start < 0:
|
||||
die("frontmatter: opening `---` has no matching closing `---`")
|
||||
raise YamlSubsetError("frontmatter: opening `---` has no matching closing `---`")
|
||||
|
||||
fm_text = text[line_starts[1]:line_starts[fm_end_lineno]] if fm_end_lineno > 1 else ""
|
||||
fm = parse_yaml_subset(fm_text)
|
||||
|
||||
@@ -13,13 +13,13 @@ Add Content-Length validation and a body-size cap to `git_http_backend.py` so ma
|
||||
|
||||
`bot_bottle/git_http_backend.py` calls `int(self.headers.get("Content-Length", 0))` without catching `ValueError`. A request with a non-numeric Content-Length raises an unhandled exception in the request handler.
|
||||
|
||||
The handler reads the full declared length into memory before passing the body to `git http-backend` with no upper bound. A local or compromised client can force arbitrarily high memory use. For comparison, `supervise_server.py` caps request bodies at 1 MiB.
|
||||
The handler reads the full declared length into memory before passing the body to `git http-backend` with no upper bound. A local or compromised client can force arbitrarily high memory use.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- A missing or non-numeric Content-Length returns HTTP 400.
|
||||
- A negative Content-Length returns HTTP 400.
|
||||
- A body larger than the cap (1 MiB, matching `supervise_server.py`) returns HTTP 413.
|
||||
- A body larger than the cap (100 MiB) returns HTTP 413.
|
||||
- Valid Git smart-HTTP pushes and fetches continue to work.
|
||||
- Unit tests cover: missing length, non-numeric length, negative length, over-cap length, and a valid push/fetch passthrough.
|
||||
|
||||
@@ -43,12 +43,12 @@ Out of scope:
|
||||
|
||||
## Design
|
||||
|
||||
Wrap the Content-Length parse in a try/except and return 400 on `ValueError`. Add an explicit check for negative values. After parsing, compare the declared length against a module-level `MAX_BODY_BYTES` constant (default 1 MiB) and return 413 if exceeded. Read exactly `min(content_length, MAX_BODY_BYTES)` bytes.
|
||||
Wrap the Content-Length parse in a try/except and return 400 on `ValueError`. Add an explicit check for negative values. After parsing, compare the declared length against a module-level `MAX_BODY_BYTES` constant (default 100 MiB) and return 413 if exceeded. Read exactly `min(content_length, MAX_BODY_BYTES)` bytes.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests using `unittest.mock` to drive the handler with crafted headers.
|
||||
- Test cases: no Content-Length header, `Content-Length: abc`, `Content-Length: -1`, `Content-Length: 2097152` (over cap), and a normal small POST body.
|
||||
- Test cases: no Content-Length header, `Content-Length: abc`, `Content-Length: -1`, a declared length above `MAX_BODY_BYTES`, and a normal small POST body.
|
||||
|
||||
Run:
|
||||
|
||||
|
||||
@@ -199,6 +199,25 @@ Named inbound detectors: `naive_injection_detection`.
|
||||
The manifest parser (`manifest_egress.py`) validates the `dlp` block and
|
||||
rejects unknown detector names.
|
||||
|
||||
### Manifest schema — `git` block
|
||||
|
||||
HTTPS Git clone/fetch traffic is not implied by a host-level egress route.
|
||||
Smart HTTP Git fetch uses `git-upload-pack`, which can transfer large repo
|
||||
packfiles and bypass the git-gate mirror path. It is therefore blocked by
|
||||
default and must be explicitly enabled per route:
|
||||
|
||||
```yaml
|
||||
egress:
|
||||
routes:
|
||||
- host: github.com
|
||||
git:
|
||||
fetch: true
|
||||
```
|
||||
|
||||
`git.fetch: true` permits read-only smart HTTP clone/fetch requests
|
||||
(`git-upload-pack`) after the normal host and `matches` checks pass. HTTPS
|
||||
Git push (`git-receive-pack`) remains blocked by the egress addon.
|
||||
|
||||
### `EgressRoute` changes
|
||||
|
||||
`EgressRoute` replaces `PathAllowlist` with `Matches` and gains two new
|
||||
@@ -232,6 +251,7 @@ class EgressRoute:
|
||||
AuthScheme: str = ""
|
||||
TokenRef: str = ""
|
||||
Role: tuple[str, ...] = ()
|
||||
GitFetch: bool = False
|
||||
OutboundDetectors: tuple[str, ...] | None = None # None = all enabled
|
||||
InboundDetectors: tuple[str, ...] | None = None # None = all enabled
|
||||
```
|
||||
@@ -252,6 +272,7 @@ class Route:
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
auth_scheme: str = ""
|
||||
token_env: str = ""
|
||||
git_fetch: bool = False
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0053: User-defined agent provider plugins
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-04
|
||||
|
||||
@@ -12,10 +12,16 @@ again at launch. Users who want to run a different agent (Gemini, Aider, a custo
|
||||
local model wrapper) cannot add a provider without forking the package.
|
||||
|
||||
This PRD opens the registry to user-defined plugins. A plugin placed at
|
||||
`~/.bot-bottle/contrib/<name>/agent_provider.py` is discovered and loaded at launch
|
||||
time. The manifest accepts any non-empty template string that names a built-in or
|
||||
resolves to a user plugin at that path. No changes to the built-in providers or the
|
||||
internal `bot_bottle/contrib/` layout.
|
||||
`~/.bot-bottle/contrib/<name>/` is discovered and loaded at launch time. The manifest
|
||||
accepts any non-empty template string that names a built-in or resolves to a user
|
||||
plugin at that path.
|
||||
|
||||
Alongside discovery, this PRD moves CA and git provisioning out of the Docker backend
|
||||
and into the `AgentProvider` ABC as overridable methods. The current standalone
|
||||
`provision/ca.py` and `provision/git.py` files in the Docker backend are deleted;
|
||||
their logic becomes the default implementations on the ABC. This lets exotic provider
|
||||
images (different base OS, different user, non-standard trust mechanism) override
|
||||
provisioning freely without the abstraction fighting them.
|
||||
|
||||
The preceding commit on this PR moves `codex_auth.py` from `bot_bottle/` into
|
||||
`bot_bottle/contrib/codex/` — a clean-up that fits naturally here since this PR
|
||||
@@ -32,33 +38,49 @@ be "cheap to add" — but "cheap" today still means a pull request against the b
|
||||
repo, not a drop-in file in the user's home directory. The filesystem layout is already
|
||||
the right shape; the discovery step is missing.
|
||||
|
||||
Beyond discovery, the Docker backend's `provision_ca` and `provision_git` functions
|
||||
bake in Debian-specific commands (`update-ca-certificates`) and a hardcoded container
|
||||
user (`node`). A user plugin that runs as a different user, or on a different base OS,
|
||||
silently gets the wrong provisioning with no way to correct it short of forking.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. A user places `~/.bot-bottle/contrib/<name>/agent_provider.py` — a file that exports
|
||||
a class inheriting `AgentProvider` — sets `agent_provider.template: <name>` in a
|
||||
bottle's frontmatter, and launches a bottle using that provider with no changes to
|
||||
the bot-bottle source.
|
||||
2. The manifest validator accepts any non-empty template string. Unknown templates that
|
||||
2. The plugin directory may also contain a `Dockerfile` at
|
||||
`~/.bot-bottle/contrib/<name>/Dockerfile`; the existing three-tier Dockerfile cascade
|
||||
(per-bottle override → manifest `dockerfile:` field → provider default) uses this
|
||||
path as the provider default for user plugins.
|
||||
3. The manifest validator accepts any non-empty template string. Unknown templates that
|
||||
resolve to no user plugin still raise a clear error, but at launch (via `get_provider`)
|
||||
rather than at manifest-load time.
|
||||
3. Built-in provider knobs (`auth_token` → claude only; `forward_host_credentials` →
|
||||
4. Built-in provider knobs (`auth_token` → claude only; `forward_host_credentials` →
|
||||
codex only) are guarded to built-in template names. Bottles using a user provider
|
||||
may set neither knob.
|
||||
4. `get_provider(template)` checks `~/.bot-bottle/contrib/<template>/agent_provider.py`
|
||||
5. `get_provider(template)` checks `~/.bot-bottle/contrib/<template>/agent_provider.py`
|
||||
before the built-ins, so a user can shadow a built-in for local testing.
|
||||
5. A clear `ValueError` is raised if the user plugin file exists but contains no
|
||||
6. A clear `ValueError` is raised if the user plugin file exists but contains no
|
||||
`AgentProvider` subclass.
|
||||
7. `AgentProvider` gains `provision_ca(self, bottle, plan)` and
|
||||
`provision_git(self, bottle, plan)` with default implementations that reproduce
|
||||
current Docker/Debian/node behavior. Built-in providers inherit the defaults
|
||||
unchanged. User plugins override either method when their image diverges.
|
||||
8. `bot_bottle/backend/docker/provision/ca.py` and
|
||||
`bot_bottle/backend/docker/provision/git.py` are deleted. The Docker backend base
|
||||
class calls `provider.provision_ca(bottle, plan)` and
|
||||
`provider.provision_git(bottle, plan)` directly.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Packaging or distributing user plugins as installable Python packages.
|
||||
- A plugin registry, index, or discovery beyond the filesystem path convention.
|
||||
- Adding a third built-in provider.
|
||||
- Changing the `AgentProvider` ABC contract — user plugins implement the same abstract
|
||||
methods as `ClaudeAgentProvider` and `CodexAgentProvider`.
|
||||
- Validating that user plugin images, Dockerfiles, or commands exist before launch
|
||||
(same policy as built-ins).
|
||||
- Sandboxing user plugin code — plugins run with full Python interpreter access.
|
||||
- Per-provider opt-out of the egress sidecar or network provisioning (follow-on).
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -70,26 +92,45 @@ the right shape; the discovery step is missing.
|
||||
- `_load_user_plugin` uses `importlib.util.spec_from_file_location` to load the module
|
||||
and returns the first `AgentProvider` subclass found in its `__dict__`. Raises
|
||||
`ValueError` if the file exists but exports no subclass.
|
||||
- The Dockerfile cascade in the Docker backend's `resolve_plan()` uses
|
||||
`~/.bot-bottle/contrib/<template>/Dockerfile` as the provider default for user
|
||||
plugins (the same slot currently occupied by `Dockerfile.claude` / `Dockerfile.codex`
|
||||
for built-ins).
|
||||
- `manifest_agent.AgentProvider.from_dict`: the `template not in PROVIDER_TEMPLATES`
|
||||
check is removed; the two built-in-specific knob guards (`auth_token` → claude,
|
||||
`forward_host_credentials` → codex) are tightened to `template in PROVIDER_TEMPLATES`
|
||||
so they are skipped for user-defined names.
|
||||
- `PROVIDER_TEMPLATES` remains in `agent_provider.py` as the set of built-in names for
|
||||
use by tests and any enumeration callers.
|
||||
- `AgentProvider` ABC gains:
|
||||
```python
|
||||
def provision_ca(self, bottle: Bottle, plan: BottlePlan) -> None: ...
|
||||
def provision_git(self, bottle: Bottle, plan: BottlePlan) -> None: ...
|
||||
```
|
||||
Default implementations reproduce the current `provision/ca.py` and
|
||||
`provision/git.py` logic exactly (Debian `update-ca-certificates`, `node` user,
|
||||
`/home/node` home).
|
||||
- `bot_bottle/backend/docker/provision/ca.py` and
|
||||
`bot_bottle/backend/docker/provision/git.py` deleted. The Docker backend base
|
||||
class substitutes direct calls to the provider methods.
|
||||
- Unit tests for the discovery path:
|
||||
- Plugin found and loaded → correct `AgentProvider` instance returned.
|
||||
- Plugin file exists but exports no subclass → `ValueError`.
|
||||
- Unknown template with no user plugin → `ValueError` from `get_provider`.
|
||||
- Built-in template name still works normally even when no user plugin exists.
|
||||
- Unit tests for the provisioning delegation:
|
||||
- A provider subclass that overrides `provision_ca` has its override called.
|
||||
- A provider subclass that overrides `provision_git` has its override called.
|
||||
- One paragraph added to `README.md` under a new "Custom providers" section describing
|
||||
the `~/.bot-bottle/contrib/<name>/agent_provider.py` convention and pointing at the
|
||||
existing contrib providers as reference implementations.
|
||||
the `~/.bot-bottle/contrib/<name>/` convention (both `agent_provider.py` and
|
||||
`Dockerfile`), the `provision_ca` / `provision_git` override points, and pointing at
|
||||
the existing contrib providers as reference implementations.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Hot-reloading plugins during a running session.
|
||||
- Plugin versioning or dependency declaration.
|
||||
- Changes to smolmachines or Docker backend provisioning paths.
|
||||
- Changes to the smolmachines backend provisioning path.
|
||||
|
||||
## Proposed design
|
||||
|
||||
@@ -136,6 +177,49 @@ def _load_user_plugin(template: str) -> AgentProvider | None:
|
||||
)
|
||||
```
|
||||
|
||||
### Dockerfile convention for user plugins
|
||||
|
||||
`resolve_plan()` in the Docker backend already has a three-tier cascade. For user
|
||||
plugins the provider-default slot is filled by:
|
||||
|
||||
```python
|
||||
Path.home() / ".bot-bottle" / "contrib" / template / "Dockerfile"
|
||||
```
|
||||
|
||||
Per-bottle overrides and manifest `dockerfile:` fields continue to take precedence.
|
||||
|
||||
### Provisioning methods on `AgentProvider`
|
||||
|
||||
```python
|
||||
class AgentProvider(ABC):
|
||||
...
|
||||
def provision_ca(self, bottle: Bottle, plan: BottlePlan) -> None:
|
||||
"""Install the egress MITM CA into the agent container's trust store.
|
||||
Override for non-Debian base images or non-standard trust mechanisms."""
|
||||
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||
bottle.exec(
|
||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||
user="root",
|
||||
)
|
||||
log_ca_fingerprint(cert_host_path, label)
|
||||
|
||||
def provision_git(self, bottle: Bottle, plan: BottlePlan) -> None:
|
||||
"""Configure git inside the agent container.
|
||||
Override for images that run as a different user or use a non-standard home."""
|
||||
_provision_cwd_git(plan, bottle)
|
||||
_provision_git_gate_config(plan, bottle)
|
||||
_provision_git_user(plan, bottle)
|
||||
```
|
||||
|
||||
The Docker backend base class replaces the direct calls to the old standalone
|
||||
functions with:
|
||||
|
||||
```python
|
||||
provider.provision_ca(bottle, plan)
|
||||
provider.provision_git(bottle, plan)
|
||||
```
|
||||
|
||||
### Manifest validation change
|
||||
|
||||
In `manifest_agent.AgentProvider.from_dict`, remove the hard rejection:
|
||||
@@ -172,10 +256,7 @@ if forward_host_credentials and template not in PROVIDER_TEMPLATES:
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Shadow order.** This PRD puts user plugins before built-ins, allowing local
|
||||
overrides. If the preference is built-ins-first (to prevent accidental shadowing),
|
||||
swap the order and document accordingly.
|
||||
2. **`BOT_BOTTLE_CONTRIB_DIR` env var.** Omitted for now — `~/.bot-bottle/contrib/`
|
||||
1. **`BOT_BOTTLE_CONTRIB_DIR` env var.** Omitted for now — `~/.bot-bottle/contrib/`
|
||||
is consistent with the rest of the user config layout. Revisit if the need surfaces.
|
||||
|
||||
## References
|
||||
@@ -184,3 +265,5 @@ if forward_host_credentials and template not in PROVIDER_TEMPLATES:
|
||||
- PRD 0048 — SSH deploy key provisioning (the `contrib/` convention)
|
||||
- `bot_bottle/agent_provider.py` — `get_provider`, `PROVIDER_TEMPLATES`, `AgentProvider` ABC
|
||||
- `bot_bottle/manifest_agent.py` — template validation that this PRD relaxes
|
||||
- `bot_bottle/backend/docker/provision/ca.py` — current CA provisioner (to be deleted)
|
||||
- `bot_bottle/backend/docker/provision/git.py` — current git provisioner (to be deleted)
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# PRD 0054: Named / Labelled Agents
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-06-03
|
||||
- **Issue:** #171
|
||||
|
||||
## Summary
|
||||
|
||||
At agent launch time, present the operator with a curses modal to optionally
|
||||
set a human-readable label and color for the agent before it launches. The
|
||||
modal pre-fills the label with the current agent name pattern (e.g.
|
||||
`implementer-a3f9`) and leaves color unset; Enter with no changes accepts
|
||||
those defaults. Store both in the bottle's `metadata.json`. Display the label —
|
||||
rendered in the chosen ANSI color — in `cli list active` output, replacing
|
||||
the bare manifest key. Inject the label and color into the in-container
|
||||
`claude.json` as `name` / `color` so Claude Code can surface them in its own
|
||||
harness when upstream support lands.
|
||||
|
||||
## Problem
|
||||
|
||||
`cli list active` identifies each running instance by its manifest agent key
|
||||
(e.g., `implementer`) plus a random slug suffix. When an operator runs three
|
||||
`implementer` bottles simultaneously — one each for three different repos —
|
||||
the output shows:
|
||||
|
||||
```
|
||||
docker a3f9 implementer egress,pipelock
|
||||
docker b81c implementer egress,pipelock
|
||||
docker d220 implementer egress,pipelock
|
||||
```
|
||||
|
||||
There is no way to tell which bottle is working on which task without attaching
|
||||
to each one in turn. The slug is opaque; the manifest key is shared. Operators
|
||||
working a multi-bottle session resort to keeping a mental map of slug→task,
|
||||
which breaks the moment they switch windows.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. After the operator selects an agent (picker or CLI argument) and backend,
|
||||
a curses modal appears before the preflight. The modal pre-fills the label
|
||||
with `<agent_name>-<slug_suffix>` (the same pattern currently shown in
|
||||
`list active`). No color is pre-selected.
|
||||
2. In the modal, any printable keystroke immediately replaces the pre-filled
|
||||
label and starts building the new name. Backspace edits normally. Enter
|
||||
at any point confirms — accepting the pre-fill if nothing was typed, or
|
||||
the in-progress text otherwise.
|
||||
3. After the label field is confirmed, the modal presents color selection:
|
||||
a list of the 16 ANSI color names the operator can navigate with arrow
|
||||
keys, or Enter / Esc with no selection to skip color entirely.
|
||||
4. `label` and `color` are stored in `BottleMetadata` and written to the
|
||||
bottle's `metadata.json`. Both fields default to `""` (empty / unset).
|
||||
5. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
|
||||
from `metadata.json`.
|
||||
6. `cli list active` shows the label when non-empty (falling back to
|
||||
`agent_name`). If a non-empty color is set and the terminal supports it,
|
||||
the label is prefixed with the appropriate ANSI escape code and reset
|
||||
afterward.
|
||||
7. `BottleSpec` carries `label` and `color`; both backends' `prepare` steps
|
||||
copy them into `BottleMetadata`.
|
||||
8. `ClaudeAgentProvider.provision_plan()` writes `label` → `"name"` and
|
||||
`color` → `"color"` into the generated `claude.json`. Fields are omitted
|
||||
when empty.
|
||||
9. `cmd_start` calls `name_color_modal` after backend selection and before
|
||||
`_launch_bottle`; passes `label` / `color` into `BottleSpec`.
|
||||
10. All existing unit tests stay green; no new tests are required for this
|
||||
change (the label/color fields are thin plumbing with no branching logic
|
||||
worth unit-testing beyond the already-tested metadata read/write path).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Showing the agent label inside the Claude Code TUI (status line, terminal
|
||||
title, custom header). That requires upstream Claude Code / codex support.
|
||||
Writing to `claude.json` is best-effort scaffolding for when that lands.
|
||||
- Validating or constraining label content beyond the 64-byte printable cap.
|
||||
- Editing the label or color of an already-running bottle.
|
||||
|
||||
## Design
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
operator input (modal)
|
||||
│
|
||||
▼
|
||||
BottleSpec.label, BottleSpec.color
|
||||
│
|
||||
├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json
|
||||
├─► smolmachines/prepare.py → BottleMetadata.label / .color → metadata.json
|
||||
│
|
||||
└─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color}
|
||||
(omitted when empty)
|
||||
|
||||
cli list active
|
||||
│
|
||||
▼
|
||||
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
|
||||
│
|
||||
▼
|
||||
cmd_list → label (with ANSI color) in the row string
|
||||
```
|
||||
|
||||
### BottleSpec changes
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class BottleSpec:
|
||||
manifest: Manifest
|
||||
agent_name: str
|
||||
copy_cwd: bool
|
||||
user_cwd: str
|
||||
identity: str = ""
|
||||
label: str = "" # operator-chosen display name; defaults to agent_name at render time
|
||||
color: str = "" # one of the 16 ANSI color names, or "" for terminal default
|
||||
```
|
||||
|
||||
`label` and `color` default to `""` so all existing callers remain valid with
|
||||
no changes.
|
||||
|
||||
### BottleMetadata changes
|
||||
|
||||
Add two new fields with backward-compatible defaults:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class BottleMetadata:
|
||||
identity: str
|
||||
agent_name: str
|
||||
cwd: str
|
||||
copy_cwd: bool
|
||||
started_at: str
|
||||
compose_project: str
|
||||
backend: str
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
```
|
||||
|
||||
`metadata.json` written by older bot-bottle versions won't have these keys;
|
||||
`read_metadata` already uses `dict.get` with defaults, so existing slugs load
|
||||
cleanly with `label=""`, `color=""`.
|
||||
|
||||
### ActiveAgent changes
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ActiveAgent:
|
||||
backend_name: str
|
||||
slug: str
|
||||
agent_name: str
|
||||
started_at: str
|
||||
services: tuple[str, ...]
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
```
|
||||
|
||||
`enumerate_active()` copies `label` and `color` out of `BottleMetadata` when
|
||||
constructing each `ActiveAgent`. The smolmachines backend gets the same
|
||||
additions for symmetry.
|
||||
|
||||
### `cli list active` rendering
|
||||
|
||||
The current row format is tab-separated:
|
||||
`{backend}\t{slug}\t{agent_name}\t{services}`
|
||||
|
||||
With labels it becomes:
|
||||
```python
|
||||
display_name = a.label if a.label else a.agent_name
|
||||
```
|
||||
|
||||
Color is rendered via ANSI escape codes. A small `_ansi_color(color_name)`
|
||||
helper returns the appropriate escape prefix for the 16 named colors, or `""`
|
||||
when the name is unrecognised or the terminal doesn't support color
|
||||
(`NO_COLOR` env var or `not sys.stdout.isatty()`).
|
||||
|
||||
The 16 ANSI color name → escape mapping:
|
||||
|
||||
| Name | ANSI code |
|
||||
|------|-----------|
|
||||
| `black` | `\033[30m` |
|
||||
| `red` | `\033[31m` |
|
||||
| `green` | `\033[32m` |
|
||||
| `yellow` | `\033[33m` |
|
||||
| `blue` | `\033[34m` |
|
||||
| `magenta` | `\033[35m` |
|
||||
| `cyan` | `\033[36m` |
|
||||
| `white` | `\033[37m` |
|
||||
| `bright-black` | `\033[90m` |
|
||||
| `bright-red` | `\033[91m` |
|
||||
| `bright-green` | `\033[92m` |
|
||||
| `bright-yellow` | `\033[93m` |
|
||||
| `bright-blue` | `\033[94m` |
|
||||
| `bright-magenta` | `\033[95m` |
|
||||
| `bright-cyan` | `\033[96m` |
|
||||
| `bright-white` | `\033[97m` |
|
||||
|
||||
Reset is `\033[0m`. Applied around the label substring only.
|
||||
|
||||
### The label+color modal
|
||||
|
||||
A single curses modal (`name_color_modal` in `bot_bottle/cli/tui.py`) handles
|
||||
both label and color in two sequential steps within the same window.
|
||||
|
||||
```python
|
||||
label, color = name_color_modal(default_label=f"{agent_name}-{slug_suffix}")
|
||||
```
|
||||
|
||||
**Step 1 — label.** The window renders:
|
||||
|
||||
```
|
||||
Name agent
|
||||
──────────────────────────────────────
|
||||
implementer-a3f9
|
||||
──────────────────────────────────────
|
||||
[any key] edit [Enter] confirm
|
||||
```
|
||||
|
||||
The pre-filled text is shown in the input field. Any printable keystroke
|
||||
immediately clears the pre-fill and starts a new name from that character
|
||||
(first-keystroke-replaces semantics). Subsequent keystrokes append normally.
|
||||
Backspace edits from the right. Enter confirms — accepting the pre-fill if
|
||||
the field was never edited, or the typed text otherwise.
|
||||
|
||||
**Step 2 — color.** After confirming the label, the window transitions to:
|
||||
|
||||
```
|
||||
Name agent
|
||||
──────────────────────────────────────
|
||||
implementer-a3f9 ← confirmed label
|
||||
──────────────────────────────────────
|
||||
Color (optional)
|
||||
> (none)
|
||||
red
|
||||
green
|
||||
blue
|
||||
…
|
||||
──────────────────────────────────────
|
||||
[↑↓] move [Enter] select [Esc] skip
|
||||
```
|
||||
|
||||
The list starts with `(none)` selected. Arrow keys move the cursor; Enter
|
||||
confirms the highlighted choice; Esc or `q` skips color. Each color name in
|
||||
the list is rendered in its own curses color so the operator can preview the
|
||||
palette.
|
||||
|
||||
The function returns `(label, color)` — both strings, `color` is `""` when
|
||||
`(none)` is selected or the step is skipped.
|
||||
|
||||
### Slug suffix for the default label
|
||||
|
||||
The default label is `<agent_name>-<slug_suffix>`, where `slug_suffix` is the
|
||||
last four characters of the slug (the same short hash shown in `list active`).
|
||||
|
||||
In `cmd_start` the slug is minted inside `prepare`, after the modal appears.
|
||||
The modal is therefore called with the manifest agent key as a fallback
|
||||
(`default_label=agent_name`). Once `prepare` returns the plan (which contains
|
||||
the slug), the `BottleSpec` is not reconstructed — the label entered by the
|
||||
operator is already in the spec. The full `<agent_name>-<slug_suffix>` form is
|
||||
only available for display in subsequent `list active` calls once the bottle
|
||||
is running.
|
||||
|
||||
### Claude Code config injection
|
||||
|
||||
Per PRD 0050, the `claude.json` trust-marker file is written by
|
||||
`ClaudeAgentProvider.provision_plan()` in
|
||||
`bot_bottle/contrib/claude/agent_provider.py`. Add `label: str = ""` and
|
||||
`color: str = ""` keyword parameters to `provision_plan()` on both the
|
||||
`AgentProvider` ABC and `ClaudeAgentProvider`, and to the
|
||||
`agent_provision_plan()` shim in `agent_provider.py`. Both `prepare.py`
|
||||
modules pass `spec.label` / `spec.color`; `CodexAgentProvider` accepts the
|
||||
params and ignores them.
|
||||
|
||||
In `ClaudeAgentProvider.provision_plan()`:
|
||||
|
||||
```python
|
||||
payload = {
|
||||
"hasCompletedOnboarding": True,
|
||||
"theme": "dark",
|
||||
"bypassPermissionsModeAccepted": True,
|
||||
"projects": claude_projects,
|
||||
}
|
||||
if label:
|
||||
payload["name"] = label
|
||||
if color:
|
||||
payload["color"] = color
|
||||
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
```
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
Two PRs, each independently mergeable.
|
||||
|
||||
### Chunk 1 — schema + storage
|
||||
|
||||
- Add `label: str = ""` and `color: str = ""` to `BottleSpec`,
|
||||
`BottleMetadata`, and `ActiveAgent`.
|
||||
- `docker/prepare.py` and `smolmachines/prepare.py`: copy `spec.label` /
|
||||
`spec.color` into `BottleMetadata`; pass them to `agent_provision_plan()`.
|
||||
- `docker/enumerate.py` and smolmachines equivalent: copy `metadata.label` /
|
||||
`metadata.color` into `ActiveAgent`.
|
||||
- Add `label: str = ""` and `color: str = ""` keyword params to
|
||||
`AgentProvider.provision_plan()` (ABC), `ClaudeAgentProvider.provision_plan()`
|
||||
(uses them in the `claude.json` write), and the `agent_provision_plan()` shim.
|
||||
`CodexAgentProvider` accepts the params and ignores them.
|
||||
- `cmd_list`: update `list active` row to use `label` when non-empty, with
|
||||
ANSI color escape codes.
|
||||
- No prompt changes; no UI changes. All existing behavior is identical.
|
||||
|
||||
### Chunk 2 — modal
|
||||
|
||||
- `bot_bottle/cli/tui.py`: add `name_color_modal(default_label)` implementing
|
||||
the two-step curses window described above.
|
||||
- `cmd_start`: call `name_color_modal(default_label=agent_name)` after backend
|
||||
selection and before `_launch_bottle`; pass `label` / `color` into
|
||||
`BottleSpec`.
|
||||
|
||||
## Open questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,148 @@
|
||||
# PRD 0055: Egress traffic logging
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-06
|
||||
- **PR:** #207
|
||||
|
||||
## Summary
|
||||
|
||||
Adds structured log levels to the egress proxy so operators can observe
|
||||
traffic and security decisions without modifying any application code.
|
||||
Three integer levels control verbosity: `0` (off), `1` (security events
|
||||
only), and `2` (full request/response capture). All output is JSON lines
|
||||
written to stderr.
|
||||
|
||||
## Problem
|
||||
|
||||
The egress proxy makes per-request allow/block decisions and DLP scans, but
|
||||
until now those decisions are invisible unless something is actively blocked
|
||||
and the caller inspects the 403 body. Debugging unexpected blocks, auditing
|
||||
what an agent is sending upstream, and verifying DLP detector behaviour all
|
||||
require adding ad-hoc instrumentation or tailing the sidecar container logs
|
||||
with no structure to grep against.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. **Level 0 (off, default):** no egress output to stderr beyond the boot
|
||||
line. Existing behaviour for production deployments.
|
||||
2. **Level 1 (blocks):** every block or DLP warn event is emitted to stderr
|
||||
as a JSON line with the event type, human-readable reason (including the
|
||||
secret type detected for DLP hits), and the request context (host, method,
|
||||
path; plus upstream status code for response-phase events). No traffic
|
||||
bodies are logged.
|
||||
3. **Level 2 (full):** all level-1 events, plus a `egress_request` JSON line
|
||||
for every forwarded request (method, path, headers, body after auth
|
||||
injection) and an `egress_response` JSON line for every response that
|
||||
passes DLP (status, headers, body).
|
||||
4. The log level is a single integer field `log` at the top of the egress
|
||||
config (routes.yaml in the sidecar; `egress.log` in the bottle manifest).
|
||||
Values other than 0, 1, 2 are rejected at parse time on both sides.
|
||||
5. The boot message includes the active log level label (`off`, `blocks`,
|
||||
`full`).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Log rotation or file sinks — stderr output is captured by the container
|
||||
runtime (Docker, smolmachines) and goes wherever the operator routes it.
|
||||
- Per-route log levels — all routes share the global level.
|
||||
- Redacting secrets from the level-2 body dump — at level 2 the operator
|
||||
has explicitly requested full visibility; redaction belongs in the
|
||||
log consumer, not the proxy.
|
||||
|
||||
## Design
|
||||
|
||||
### Wire format
|
||||
|
||||
`routes.yaml` gains an optional top-level `log` key:
|
||||
|
||||
```yaml
|
||||
log: 1 # 0 = off (default), 1 = blocks, 2 = full
|
||||
routes:
|
||||
- host: "api.anthropic.com"
|
||||
...
|
||||
```
|
||||
|
||||
The field is omitted entirely when the level is 0 (default).
|
||||
|
||||
### Manifest format
|
||||
|
||||
```yaml
|
||||
egress:
|
||||
log: 1
|
||||
routes:
|
||||
- host: "api.anthropic.com"
|
||||
...
|
||||
```
|
||||
|
||||
`egress.log` accepts integers 0, 1, or 2. Booleans and strings are rejected.
|
||||
|
||||
### Log events
|
||||
|
||||
**Block / DLP block (level ≥ 1):**
|
||||
```json
|
||||
{
|
||||
"event": "egress_block",
|
||||
"reason": "egress DLP: GitHub token (classic) found in request",
|
||||
"host": "api.github.com",
|
||||
"method": "POST",
|
||||
"path": "/gists"
|
||||
}
|
||||
```
|
||||
|
||||
Response-phase block also includes `"response_status"`.
|
||||
|
||||
**DLP warn (level ≥ 1):**
|
||||
```json
|
||||
{
|
||||
"event": "egress_warn",
|
||||
"reason": "egress DLP: possible prompt injection detected",
|
||||
"host": "api.anthropic.com",
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"response_status": 200
|
||||
}
|
||||
```
|
||||
|
||||
**Forwarded request (level 2):**
|
||||
```json
|
||||
{
|
||||
"event": "egress_request",
|
||||
"host": "api.anthropic.com",
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"headers": { "authorization": "Bearer sk-ant-...", "content-type": "application/json" },
|
||||
"body": "{\"model\": \"claude-opus-4-8\", ...}"
|
||||
}
|
||||
```
|
||||
|
||||
The request is logged after auth injection, so the outgoing `Authorization`
|
||||
header is present. The agent's original `Authorization` header is stripped
|
||||
before logging.
|
||||
|
||||
**Response (level 2):**
|
||||
```json
|
||||
{
|
||||
"event": "egress_response",
|
||||
"host": "api.anthropic.com",
|
||||
"status": 200,
|
||||
"headers": { "content-type": "application/json" },
|
||||
"body": "{\"id\": \"msg_...\", ...}"
|
||||
}
|
||||
```
|
||||
|
||||
Responses are logged before DLP scanning, so the body is always the raw
|
||||
upstream response.
|
||||
|
||||
### Implementation
|
||||
|
||||
- **`egress_addon_core.py`**: `Config.log: int = LOG_OFF` (`LOG_OFF=0`,
|
||||
`LOG_BLOCKS=1`, `LOG_FULL=2`). `parse_config()` validates the integer and
|
||||
rejects booleans.
|
||||
- **`egress_addon.py`**: `_block()` emits JSON when `log >= LOG_BLOCKS`. The
|
||||
`_req_ctx()` helper builds `{host, method, path}` for every call site.
|
||||
`_log_request()` / `_log_response()` fire when `log >= LOG_FULL`.
|
||||
- **`manifest_egress.py`**: `EgressConfig.Log: int = 0`, parsed from
|
||||
`egress.log`, validated against `{0, 1, 2}`.
|
||||
- **`egress.py`**: `egress_render_routes(routes, *, log: int = 0)` emits
|
||||
`log: N` at the top of routes.yaml when N > 0. `EgressPlan.log: int = 0`.
|
||||
@@ -0,0 +1,170 @@
|
||||
# PRD 0056: Extended outbound DLP scan surfaces
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-06
|
||||
- **Issue:** #204
|
||||
|
||||
## Summary
|
||||
|
||||
The outbound DLP scan introduced in PRD 0052 covers only the request body and
|
||||
the `Authorization` header. An agent can exfiltrate a provisioned secret or
|
||||
credential token via several other request surfaces that are currently
|
||||
unscanned: arbitrary HTTP headers, URL query parameters, URL path segments,
|
||||
and DNS-level hostnames (DNS tunnelling). This PRD closes those gaps by
|
||||
extending the scan corpus to cover all four surfaces.
|
||||
|
||||
## Problem
|
||||
|
||||
After PRD 0052, `egress_addon.py` builds the DLP scan text as:
|
||||
|
||||
```python
|
||||
auth_header = flow.request.headers.get("authorization", "")
|
||||
scan_text = body
|
||||
if auth_header:
|
||||
scan_text = auth_header + "\n" + body
|
||||
```
|
||||
|
||||
This leaves the following attack surfaces unscanned:
|
||||
|
||||
| Surface | Example exfiltration vector |
|
||||
|---|---|
|
||||
| Other request headers | `X-Api-Key: <secret>`, `Cookie: token=<secret>` |
|
||||
| Query parameters | `GET /search?api_key=<secret>` |
|
||||
| URL path segments | `GET /proxy/<base64-secret>/endpoint` |
|
||||
| DNS hostname | `<base64-secret>.attacker.com` (DNS tunnelling) |
|
||||
|
||||
Each surface allows a determined agent to bypass the current scan and leak
|
||||
a provisioned `EGRESS_TOKEN_*` value or a known token pattern to an
|
||||
upstream attacker.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. All four surfaces (headers, query params, path, hostname) are included in
|
||||
the outbound DLP scan text for every route that has outbound scanning
|
||||
enabled.
|
||||
2. A pure helper `build_outbound_scan_text(host, path, query, headers, body)`
|
||||
in `egress_addon_core.py` assembles the scan corpus so the logic is fully
|
||||
unit-testable without a mitmproxy dependency.
|
||||
3. Unit tests demonstrate that `scan_outbound` blocks a request when a known
|
||||
token pattern or provisioned secret appears in each surface independently.
|
||||
4. No manifest schema changes — the `dlp` block's `outbound_detectors`
|
||||
field continues to control which detectors run; all surfaces are scanned
|
||||
by whichever detectors are active.
|
||||
5. The auth-strip ordering invariant from PRD 0052 is preserved: the
|
||||
outbound scan sees the original `Authorization` header before the addon
|
||||
strips it.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Raw UDP/DNS queries — these bypass the HTTP proxy entirely and require a
|
||||
network-level DNS sinkhole (tracked separately in issue #205).
|
||||
- Structured query-param parsing — scanning the raw query string is
|
||||
sufficient.
|
||||
- Changes to the `dlp` block schema or detector names.
|
||||
- Scanning outbound request bodies for prompt injection (inbound only,
|
||||
per PRD 0052 design).
|
||||
- LLM-based semantic detection or entropy-based secret scanning (deferred,
|
||||
per PRD 0052 non-goals).
|
||||
|
||||
## Design
|
||||
|
||||
### `build_outbound_scan_text` in `egress_addon_core.py`
|
||||
|
||||
A new pure function assembles all request surfaces into a single newline-
|
||||
delimited string suitable for passing to `scan_outbound`:
|
||||
|
||||
```python
|
||||
def build_outbound_scan_text(
|
||||
host: str,
|
||||
path: str,
|
||||
query: str,
|
||||
headers: typing.Mapping[str, str],
|
||||
body: str,
|
||||
) -> str:
|
||||
parts: list[str] = [host, path]
|
||||
if query:
|
||||
parts.append(query)
|
||||
for name, value in headers.items():
|
||||
parts.append(f"{name}: {value}")
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
```
|
||||
|
||||
**Why hostname in the scan corpus?**
|
||||
DNS tunnelling encodes data into subdomain labels
|
||||
(`<base64-secret>.attacker.com`). The mitmproxy `request` hook sees the
|
||||
`pretty_host` field before the TCP connection is fully established, so
|
||||
scanning it catches this vector. Both the `token_patterns` and
|
||||
`known_secrets` detectors handle encoded variants (raw, base64, URL-encoded,
|
||||
hex), so the existing encoding-variant logic in `_encoded_variants` already
|
||||
covers common DNS-tunnelling encodings.
|
||||
|
||||
### `egress_addon.py` update
|
||||
|
||||
The narrow scan-text construction is replaced with a call to
|
||||
`build_outbound_scan_text`, which the addon has already split `path` and
|
||||
`query` from `flow.request.path` at the top of `request()`:
|
||||
|
||||
```python
|
||||
# Build full scan corpus: hostname + path + query + all headers + body
|
||||
body = flow.request.get_text(strict=False) or ""
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host,
|
||||
request_path,
|
||||
query,
|
||||
dict(flow.request.headers),
|
||||
body,
|
||||
)
|
||||
dlp_result = scan_outbound(route, scan_text, os.environ)
|
||||
```
|
||||
|
||||
The `Authorization` header is present in `flow.request.headers` at this
|
||||
point (the strip happens below on line 115), so the auth-strip ordering
|
||||
invariant is automatically preserved.
|
||||
|
||||
### `build_inbound_scan_text` in `egress_addon_core.py`
|
||||
|
||||
An analogous helper assembles the inbound response corpus (all response
|
||||
headers + body) for `scan_inbound`. The `response()` hook now passes this
|
||||
combined text instead of the body alone, closing the response-header
|
||||
injection vector.
|
||||
|
||||
### WebSocket frame scanning
|
||||
|
||||
A new `websocket_message` hook in `EgressAddon` scans every frame after the
|
||||
HTTP 101 upgrade. Outbound frames (`from_client=True`) are scanned for
|
||||
credential patterns and known secrets; inbound frames are scanned for prompt
|
||||
injection. On a block the entire WebSocket connection is killed via
|
||||
`flow.kill()` (there is no HTTP response surface to write to after upgrade).
|
||||
|
||||
### Extended encoding variants in `_encoded_variants`
|
||||
|
||||
`_encoded_variants` is extended from 4 to 9 encoding forms:
|
||||
|
||||
| Added encoding | Rationale |
|
||||
|---|---|
|
||||
| Standard base64 without padding | Common in log lines where `=` is stripped |
|
||||
| URL-safe base64 with padding | JWT / OAuth standard alphabet |
|
||||
| URL-safe base64 without padding | Same, padding stripped |
|
||||
| Hex uppercase | Complements existing hex-lowercase variant |
|
||||
| Base32 | TOTP seeds; some DNS-exfil channels use base32 subdomains |
|
||||
| gzip + base64 | Recognisable by `H4sI` prefix; naive compression before encode |
|
||||
|
||||
### OpenAI project key pattern
|
||||
|
||||
`TOKEN_PATTERNS` gains `sk-proj-[A-Za-z0-9_\-]{48,}` covering OpenAI's
|
||||
newer project-scoped API key format.
|
||||
|
||||
## Implementation
|
||||
|
||||
Delivered across three commits on the same branch:
|
||||
|
||||
1. **Outbound scan surfaces** — `build_outbound_scan_text`, `egress_addon.py`
|
||||
`request()` rewrite, `TestBuildOutboundScanText`, `TestScanOutbound`.
|
||||
2. **Remaining gaps** — extended `_encoded_variants`, `sk-proj-` pattern,
|
||||
`build_inbound_scan_text`, response-header scanning, `websocket_message`
|
||||
hook, and matching unit tests.
|
||||
3. **PRD flip** — `Status: Draft → Active` (committed with the first
|
||||
implementation commit; updated here to reflect final scope).
|
||||
@@ -0,0 +1,79 @@
|
||||
# PRD 0057: Promote smolmachines to default backend; convert Docker to example-only
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-06-06
|
||||
- **Issue:** #206
|
||||
|
||||
## Summary
|
||||
|
||||
Make smolmachines the default bot-bottle backend and demote Docker to an example-only configuration. This closes the DNS sinkhole gap that exists in the Docker backend: the mitmproxy egress addon intercepts HTTP(S) but cannot see raw UDP port-53 DNS queries, so an agent can exfiltrate data via DNS tunnelling without the egress guard seeing it. The smolmachines backend eliminates this gap at the VMM layer — DNS filtering is built in and the agent container cannot bypass it.
|
||||
|
||||
## Problem
|
||||
|
||||
The current default backend is Docker. The egress addon (PRDs 0052/0053) intercepts HTTPS and scans request/response surfaces, but it is an HTTP proxy: raw UDP/TCP port-53 DNS queries go to the OS resolver and never pass through it. An agent can encode secrets as base32 or hex subdomains in a DNS query (`<encoded>.attacker.com`) and exfiltrate them silently.
|
||||
|
||||
The smolmachines backend already solves this: its Transport Socket Interface (TSI) enforces a CIDR allowlist at the VMM layer, and DNS is handled via vsock port 6002 — the guest's `/etc/resolv.conf` points at `127.0.0.1`, and a guest-side DNS proxy tunnels queries over vsock to the host, which returns NXDOMAIN for anything not on the allowlist. The agent cannot bypass this by hardcoding IPs or by configuring an alternate resolver, because both mechanisms are enforced below the guest OS.
|
||||
|
||||
Docker has no equivalent. Adding dnsmasq to the Docker backend would close the gap at some cost (dnsmasq sidecar, iptables `NET_ADMIN`, per-launch config generation), but it is the wrong direction if smolmachines supersedes Docker anyway.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `BOT_BOTTLE_BACKEND` defaults to `smolmachines` when not set.
|
||||
- The existing Docker backend remains functional (not removed) but is no longer the default and is documented as legacy/example-only.
|
||||
- Example bottles (`examples/bottles/`) reference smolmachines, not Docker.
|
||||
- `AGENTS.md` documents the backend choice and the DNS gap closure.
|
||||
- Existing Docker-backed integration tests continue to pass; they select Docker explicitly via `BOT_BOTTLE_BACKEND=docker` rather than relying on the default.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Removing the Docker backend or its tests.
|
||||
- Implementing a dnsmasq layer for the Docker backend (closed by this change; not needed on the default path).
|
||||
- Iptables / `NET_ADMIN` work for Docker (deferred).
|
||||
- Subdomain-depth filtering for allowlisted zones (documented residual gap; tracked separately per the issue).
|
||||
|
||||
## Design
|
||||
|
||||
### Default backend change
|
||||
|
||||
`bot_bottle/backend/__init__.py`, line ~440:
|
||||
|
||||
```python
|
||||
# Before
|
||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "docker"
|
||||
|
||||
# After
|
||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines"
|
||||
```
|
||||
|
||||
### DNS gap closure (how smolmachines handles it)
|
||||
|
||||
When the smolmachines backend launches an agent VM:
|
||||
|
||||
1. The VM's network device uses TSI (`--allow-host` / `--allow-cidr` flags), which enforces a CIDR allowlist at the VMM layer. The guest cannot dial IPs outside the allowlist even with raw sockets.
|
||||
2. The guest's `/etc/resolv.conf` is set to `127.0.0.1`; a guest-side DNS proxy relays queries over vsock port 6002 to the host.
|
||||
3. The host-side DNS filter returns NXDOMAIN for any hostname not in the allowlist derived from `egress.routes` in the bottle manifest.
|
||||
|
||||
This means DNS exfiltration via unknown subdomains is blocked by NXDOMAIN before the query leaves the host, and even if the agent hardcoded the IP of an attacker-controlled server, TSI would drop the packet at the VMM layer.
|
||||
|
||||
**Residual gap:** if the attacker controls a subdomain of an allowlisted zone (e.g., a legitimate zone like `api.anthropic.com` that the attacker can inject into via a separate compromise), DNS queries for that subdomain would be forwarded. This is accepted and documented.
|
||||
|
||||
### Example bottles
|
||||
|
||||
Update `examples/bottles/dev.md` and `examples/bottles/claude.md` to remove Docker-specific notes and reference smolmachines as the runtime.
|
||||
|
||||
### Integration test migration
|
||||
|
||||
Tests that exercise the Docker backend explicitly should set `BOT_BOTTLE_BACKEND=docker` rather than relying on the default. Tests that are backend-agnostic continue to use whatever `BOT_BOTTLE_BACKEND` is set to (defaulting to smolmachines in the test environment if available).
|
||||
|
||||
## Resolved questions
|
||||
|
||||
- **TSI + egress proxy loopback.** The implementation uses a per-bottle loopback alias rather than broad `127.0.0.1` passthrough. The smolmachines launch integration test now asserts that the guest receives proxy env vars on a `127.x` alias, can reach an allowlisted host through the proxy, cannot reach the same host directly with proxy vars unset, and cannot reach a non-allowlisted host through the proxy.
|
||||
- **smolmachines availability check.** The smolmachines preflight error points operators at the smolvm installer and explicitly suggests `BOT_BOTTLE_BACKEND=docker` / `--backend=docker` for legacy Docker-backed runs.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/smolmachines-as-vm-backend.md` — smolmachines evaluation
|
||||
- `docs/research/network-egress-guard.md` — Approach 4 (DNS-based egress control)
|
||||
- `docs/research/secret-exfil-tripwire-encodings.md` — DNS exfil discussion
|
||||
- PRD 0052, PRD 0053 — egress DLP addon (HTTP-level; partial mitigation only)
|
||||
@@ -0,0 +1,123 @@
|
||||
# PRD 0058: Add built-in Pi agent provider
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** codex
|
||||
- **Created:** 2026-06-09
|
||||
- **Issue:** #221
|
||||
|
||||
## Summary
|
||||
|
||||
Add `pi` as a built-in `agent_provider.template`. The provider runs the Pi
|
||||
coding-agent CLI, provisions its agent config under `~/.pi/agent`, and writes a
|
||||
provider settings file that targets an unauthenticated Ollama-compatible server.
|
||||
|
||||
The default settings assume an Ollama server at `http://ollama:11434/v1`, using
|
||||
the `openai-completions` API with a dummy API key because Ollama ignores it.
|
||||
Users can override the provider id, base URL, model list, API key, API-key env
|
||||
reference, API type, and compatibility flags through a new
|
||||
`agent_provider.settings` object.
|
||||
|
||||
## Problem
|
||||
|
||||
bot-bottle currently ships Claude and Codex as built-in agent providers. Pi is a
|
||||
useful third harness, but using it today requires a custom provider plugin and a
|
||||
custom image. That repeats boilerplate for prompt copying, skill copying,
|
||||
provider config, and runtime registration.
|
||||
|
||||
Pi's local-model path is also easy to misconfigure: its custom-model docs require
|
||||
`~/.pi/agent/models.json`, an API entry, at least one model id, and a dummy
|
||||
`apiKey` for Ollama even though the server does not authenticate. bot-bottle
|
||||
should generate that shape consistently.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `agent_provider.template: pi` is accepted as a built-in provider.
|
||||
- `bot_bottle/contrib/pi/` provides a Pi image and `PiAgentProvider`.
|
||||
- Pi receives the bot-bottle prompt at `~/.bot-bottle-prompt.txt` and starts in
|
||||
print-mode prompt delivery like Codex.
|
||||
- Pi skills are copied into `~/.pi/agent/skills/<name>/`.
|
||||
- Pi provider settings are configurable from the bottle manifest via
|
||||
`agent_provider.settings`.
|
||||
- The default Pi provider settings configure an unauthenticated Ollama-compatible
|
||||
server.
|
||||
- Unit tests cover manifest parsing, runtime selection, plan generation, prompt,
|
||||
skills, and provider provisioning.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Managing or launching an Ollama server.
|
||||
- Authenticating to Ollama or any remote Pi provider.
|
||||
- Forwarding host Pi credentials.
|
||||
- Implementing Pi extensions or MCP registration.
|
||||
- Changing Claude or Codex provider behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Manifest
|
||||
|
||||
Extend `agent_provider` with an optional `settings` object. It is currently only
|
||||
supported for built-in `pi`.
|
||||
|
||||
Supported keys:
|
||||
|
||||
- `base_url`: string, defaults to `http://ollama:11434/v1`
|
||||
- `provider`: string, defaults to `ollama`
|
||||
- `api`: string, defaults to `openai-completions`
|
||||
- `api_key`: string, defaults to `ollama`
|
||||
- `api_key_env`: string, optional host env var name for egress auth injection
|
||||
- `models`: non-empty array of strings, defaults to `["qwen2.5-coder:7b"]`
|
||||
- `context_window`: positive integer, defaults to `4096`; this is the Ollama
|
||||
runtime context, and bot-bottle subtracts `max_tokens` before writing Pi's
|
||||
`contextWindow` so output space is reserved
|
||||
- `max_tokens`: positive integer, defaults to `1024`
|
||||
- `max_tokens_field`: `max_tokens` or `max_completion_tokens`, defaults to
|
||||
`max_tokens`
|
||||
- `supports_developer_role`: boolean, defaults to `false`
|
||||
- `supports_reasoning_effort`: boolean, defaults to `false`
|
||||
|
||||
The snake-case manifest keys are converted into Pi's JSON field names:
|
||||
`baseUrl`, `apiKey`, `contextWindow`, `maxTokens`,
|
||||
`supportsDeveloperRole`, and `supportsReasoningEffort`. `context_window`
|
||||
describes the server's total context; Pi's `contextWindow` receives
|
||||
`context_window - max_tokens` because Pi uses it as an input compaction target.
|
||||
|
||||
`api_key` and `api_key_env` are mutually exclusive. When targeting a hosted
|
||||
provider through bot-bottle's egress sidecar, omit `api_key` and set
|
||||
`api_key_env` to the host env var that holds the API key. The generated
|
||||
`models.json` receives only an `egress-placeholder` API key, and the egress
|
||||
route injects the real `Authorization` header from the sidecar env. For example,
|
||||
OpenRouter can use provider id `openrouter` with
|
||||
`api_key_env: OPENROUTER_API_KEY`, keeping the key out of the agent env and
|
||||
`models.json`.
|
||||
|
||||
### Provider
|
||||
|
||||
`PiAgentProvider.provision_plan` writes `models.json` into the per-launch state
|
||||
directory and returns an `AgentProvisionPlan` that copies it to
|
||||
`~/.pi/agent/models.json`. The provider also declares an unauthenticated egress
|
||||
route for the configured base URL host so the egress layer can allow the Ollama
|
||||
endpoint.
|
||||
|
||||
The Pi runtime uses:
|
||||
|
||||
- `command="pi"`
|
||||
- `prompt_mode="append_system_prompt"`
|
||||
- `image="bot-bottle-pi:latest"`
|
||||
- `bypass_args=()`
|
||||
- `resume_args=()`
|
||||
- `remote_control_args=()`
|
||||
|
||||
The Dockerfile installs `@earendil-works/pi-coding-agent` globally from npm and
|
||||
keeps the same Debian/node base shape as the existing provider images.
|
||||
|
||||
### Supervise MCP
|
||||
|
||||
Pi does not have built-in MCP support in the current public docs, so
|
||||
`provision_supervise_mcp` is a no-op. This keeps Pi bottles launchable with
|
||||
`supervise: true` while preserving the explicit non-goal of implementing Pi
|
||||
extensions.
|
||||
|
||||
## Merge rule(s)
|
||||
|
||||
This PR can merge when the focused unit tests pass and the PRD status is flipped
|
||||
from Draft to Active in the final implementation commit.
|
||||
@@ -0,0 +1,190 @@
|
||||
# PRD 0059: macOS Container backend
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Codex
|
||||
- **Created:** 2026-06-10
|
||||
- **Issue:** #220
|
||||
|
||||
## Summary
|
||||
|
||||
Add a `macos-container` backend that integrates Apple's `container`
|
||||
CLI as a host runtime on macOS. The shipped slices register the
|
||||
backend, implement reusable host primitives (`build`, `exec`, `cp`,
|
||||
image inspection, cleanup, active enumeration), make launch runnable
|
||||
with the proven two-network sidecar topology, and add real-runtime
|
||||
coverage without weakening bot-bottle's sidecar egress model.
|
||||
|
||||
## Problem
|
||||
|
||||
bot-bottle currently has two local execution paths:
|
||||
|
||||
- `docker`, which runs the whole bottle topology through Docker
|
||||
Compose.
|
||||
- `smolmachines`, which runs the agent in smolvm but still depends on
|
||||
Docker for the sidecar bundle and image-building pipeline.
|
||||
|
||||
Issue #220 explored removing Docker as a host dependency. A follow-up
|
||||
review comment verified that smolvm can publish guest ports back to
|
||||
host loopback and that another smolvm guest can reach that service
|
||||
through the existing per-bottle loopback alias plus `--allow-cidr`
|
||||
path. That keeps the VM-contained sidecar direction viable and rejects
|
||||
the host-process sidecar fallback.
|
||||
|
||||
Apple's `container` CLI is another macOS-native way to run OCI images
|
||||
as lightweight Linux VMs. Its current command surface includes
|
||||
Docker-like `build`, `run`, `exec`, `cp`, port publishing, image
|
||||
inspection, and user-defined networks. That makes it a plausible local
|
||||
backend, but it does not remove the need to preserve bot-bottle's
|
||||
sidecar enforcement property: the agent must not have a direct egress
|
||||
path around the egress sidecar.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `--backend=macos-container` and
|
||||
`BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing
|
||||
backend selector.
|
||||
- Compatible macOS hosts default to `macos-container` when
|
||||
`BOT_BOTTLE_BACKEND` and `--backend` are both unset.
|
||||
- Backend availability is true only on macOS hosts with `container` on
|
||||
`PATH`.
|
||||
- The backend has tested wrappers for Apple Container image build,
|
||||
image inspection, container `exec`, container `cp`, cleanup, and
|
||||
active-agent enumeration.
|
||||
- Full launch uses a host-only internal network for the agent and a
|
||||
separate NAT egress network for the sidecar bundle.
|
||||
- The agent container does not attach to the egress network. It reaches
|
||||
allowed outbound hosts through HTTP(S)_PROXY pointing at the
|
||||
sidecar's internal-network IP.
|
||||
- `bottle.git` / git-gate bottles fail loudly on this backend until a
|
||||
safe Apple Container key-delivery path exists.
|
||||
- Real-runtime integration coverage is present and guarded by macOS and
|
||||
Apple Container availability.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not remove or deprecate the Docker backend.
|
||||
- Do not remove or deprecate the smolmachines backend.
|
||||
- Do not run sidecar daemons as host processes.
|
||||
- Do not launch a degraded backend where the agent can bypass the
|
||||
egress sidecar through direct network access.
|
||||
- Do not require Docker Desktop as part of the macOS Container backend.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend name
|
||||
|
||||
The selectable backend name is `macos-container`. The Python package
|
||||
uses `bot_bottle.backend.macos_container` because module names cannot
|
||||
contain hyphens.
|
||||
|
||||
### Availability and preflight
|
||||
|
||||
`MacosContainerBottleBackend.is_available()` returns true only when:
|
||||
|
||||
- `platform.system() == "Darwin"`
|
||||
- `container` is discoverable on `PATH`
|
||||
|
||||
`prepare()` calls `require_container()`, which produces a concrete
|
||||
install pointer and rejects non-macOS hosts.
|
||||
|
||||
### Implemented primitives
|
||||
|
||||
The backend owns an Apple Container wrapper module instead of reusing
|
||||
Docker wrappers. The wrapper maps bot-bottle's backend needs to
|
||||
Apple's CLI:
|
||||
|
||||
| bot-bottle need | Apple Container command |
|
||||
|---|---|
|
||||
| Build provider image | `container build -t <ref> [-f Dockerfile] <context>` |
|
||||
| Run agent commands | `container exec [--interactive --tty] <id> ...` |
|
||||
| Copy files into guest | `container cp <host> <id>:<path>` |
|
||||
| Inspect image identity | `container image inspect <ref>` |
|
||||
| Cleanup stale containers | `container delete --force <id>` |
|
||||
| Cleanup stale networks | `container network delete <name>` |
|
||||
| Active enumeration | `container list --quiet` |
|
||||
|
||||
The bottle handle mirrors `DockerBottle`: it builds a host argv for
|
||||
foreground agent execution, pipes shell snippets through stdin for
|
||||
`Bottle.exec`, and exposes `cp_in` for provisioning.
|
||||
|
||||
### Launch topology
|
||||
|
||||
`launch()` uses Apple Container's two-network topology:
|
||||
|
||||
- create a host-only internal network for the bottle;
|
||||
- create a normal NAT egress network for the sidecar bundle;
|
||||
- start the sidecar bundle attached to the egress network first and the
|
||||
internal network second;
|
||||
- discover the sidecar's internal-network IPv4 address from
|
||||
`container inspect`;
|
||||
- start the agent attached only to the internal network, with
|
||||
HTTP_PROXY / HTTPS_PROXY / lowercase proxy vars pointing at the
|
||||
sidecar IP and egress port.
|
||||
|
||||
This keeps the agent off the outbound network while preserving the
|
||||
proxy-env contract that existing agent tooling already honors. The
|
||||
integration smoke also removes the proxy env in-guest and confirms
|
||||
direct egress fails.
|
||||
|
||||
### Deferred git-gate support
|
||||
|
||||
Apple Container currently rejects single-file bind mounts, and
|
||||
`container cp` into a stopped container is not available. Starting the
|
||||
container earlier would allow `container cp` into a running container,
|
||||
but it would also mean delivering SSH private key material into a live
|
||||
sidecar before the git-gate daemon is ready to own it. Mounting broad
|
||||
host SSH directories is not acceptable.
|
||||
|
||||
For this PRD, `bottle.git` / git-gate support is explicitly deferred on
|
||||
the `macos-container` backend. Bottles with git-gate upstreams fail
|
||||
loudly and should use `docker` or `smolmachines` until a narrower key
|
||||
delivery design lands.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. Register `macos-container`, add availability/preflight, bottle
|
||||
handle, utility wrappers, cleanup, active enumeration, unit tests,
|
||||
and this PRD.
|
||||
2. Spike Apple Container networking against real macOS 26 hosts:
|
||||
repeated `--network`, internal network egress behavior, published
|
||||
loopback reachability from another container, DNS behavior, and
|
||||
labels/JSON output stability.
|
||||
3. Implement launch once the enforcement shape is proven. Reuse the
|
||||
existing sidecar bundle image and daemon subset env contract where
|
||||
possible.
|
||||
4. Add real-runtime integration tests guarded by `container` presence
|
||||
and macOS version.
|
||||
5. Consider moving smolmachines sidecar/image-building work to
|
||||
VM-contained or Apple Container-backed execution only after the
|
||||
`macos-container` launch path is trustworthy.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests cover backend registration through `known_backend_names`.
|
||||
- Unit tests cover availability/preflight behavior without requiring
|
||||
macOS.
|
||||
- Unit tests cover `MacosContainerBottle` command construction and
|
||||
stdin-based shell execution.
|
||||
- Unit tests cover cleanup and active enumeration parsing.
|
||||
- Unit tests cover launch argv/env construction, sidecar mount
|
||||
staging, sidecar IP parsing, and git-gate rejection.
|
||||
- Integration tests run on macOS hosts with Apple Container installed
|
||||
and verify that egress cannot bypass the sidecar. They also preflight
|
||||
Apple Container BuildKit DNS because image builds must resolve
|
||||
package mirrors before a launch smoke can be meaningful. The backend
|
||||
probes the running builder before image builds and leaves it alone
|
||||
when its current resolver works. If the probe fails, or if the
|
||||
operator explicitly sets `BOT_BOTTLE_MACOS_CONTAINER_DNS`, the backend
|
||||
restarts the Apple Container builder with the configured DNS server.
|
||||
Without an explicit override, that server is discovered from the
|
||||
host's directly reachable IPv4 resolver before falling back to a
|
||||
public resolver.
|
||||
|
||||
## References
|
||||
|
||||
- [Issue #220 review comment](https://gitea.dideric.is/didericis/bot-bottle/issues/220#issuecomment-1980):
|
||||
smolvm `--port/-p` can expose a guest service to host loopback, and
|
||||
another smolvm guest can reach it through the existing per-bottle
|
||||
loopback alias path.
|
||||
- Apple Container command reference: `container run`, `build`, `exec`,
|
||||
port publishing, and network commands.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user