Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47cb50db4b | |||
| a750a242d1 | |||
| 4544899c79 | |||
| 89d2ba7e82 | |||
| 43a92945a3 | |||
| 7abcb2c7df | |||
| efa1c8ee4b | |||
| 249eaff53d | |||
| 9f65ab016d | |||
| 46b7e06b37 | |||
| 93190d5e0c | |||
| a2113b7f43 | |||
| d8c5532168 | |||
| a27b1a5fe9 | |||
| 1a9056c648 | |||
| 63595f123a | |||
| c89847b626 | |||
| cd9f023f3d | |||
| 5c499d290b | |||
| 0500ae5f4c | |||
| 5614a56ccd | |||
| 71c0f8ed88 | |||
| 556217ae7b | |||
| 7461802b62 | |||
| 5c12c6bf4e | |||
| 4096409495 | |||
| 8d0782d1c7 | |||
| 6793a09e2b | |||
| af293d7036 | |||
| d415581adc | |||
| 27b9ba5247 | |||
| 420184b874 | |||
| 7938b90d19 | |||
| d879258f62 | |||
| 78d3b43061 | |||
| cdc5c8203d | |||
| d04bbf1454 | |||
| c00097d998 | |||
| 6630a1f701 | |||
| a7ad82f03a | |||
| 9b8f126b8f | |||
| d167c03215 | |||
| 479b93bd0b | |||
| 2de61eeb17 | |||
| a4bc5d4508 | |||
| 7ebd067d2c | |||
| be50354100 | |||
| e1163184c9 | |||
| 70f6a9be20 | |||
| aa43fd032f | |||
| be5c803e8e | |||
| 38ff4a5e88 | |||
| 8df76f6126 | |||
| b63c41db9c | |||
| 6c52686069 | |||
| a52245af95 | |||
| d4e48a4169 | |||
| 53cc3ca492 | |||
| bafb73fdb9 | |||
| b0f317c499 | |||
| 57eb4394dc | |||
| 82e1a353bd | |||
| a6e1aebda1 |
@@ -19,6 +19,19 @@ name: pre-release-test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Revision to qualify
|
||||
required: false
|
||||
default: main
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
@@ -26,6 +39,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
@@ -66,6 +81,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
# No actions/setup-python (see the note in the `unit` job); the
|
||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||
@@ -138,10 +155,11 @@ jobs:
|
||||
# the old build-infra → integration-firecracker + coverage chain incurred.
|
||||
integration-firecracker:
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Preflight — Firecracker host is ready
|
||||
run: |
|
||||
@@ -212,13 +230,14 @@ jobs:
|
||||
# Python >=3.11 with `coverage` importable on the launchd service PATH.
|
||||
integration-macos:
|
||||
runs-on: [self-hosted, macos]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
concurrency:
|
||||
group: integration-macos-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job go
|
||||
@@ -290,6 +309,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install coverage
|
||||
@@ -340,6 +360,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the tested revision
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Download the tested rootfs
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
name: publish-artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Branch, tag, or commit to publish
|
||||
required: true
|
||||
default: main
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
source_commit:
|
||||
description: Published immutable source commit
|
||||
value: ${{ jobs.resolve.outputs.sha }}
|
||||
secrets:
|
||||
BOT_BOTTLE_RELEASE_TOKEN:
|
||||
required: true
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: publish-artifacts-${{ inputs.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.revision.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
- id: revision
|
||||
name: Resolve the immutable source revision
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
oci:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
orchestrator: ${{ steps.images.outputs.orchestrator }}
|
||||
gateway: ${{ steps.images.outputs.gateway }}
|
||||
agent_claude: ${{ steps.images.outputs.agent_claude }}
|
||||
agent_codex: ${{ steps.images.outputs.agent_codex }}
|
||||
agent_pi: ${{ steps.images.outputs.agent_pi }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- name: Log in to the package registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.dideric.is
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
|
||||
- name: Set up multi-architecture builds
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- id: images
|
||||
name: Build, smoke-test, and publish OCI images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python_base=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
|
||||
node_base=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
|
||||
sha='${{ needs.resolve.outputs.sha }}'
|
||||
registry=gitea.dideric.is/didericis
|
||||
|
||||
build_image() {
|
||||
key=$1
|
||||
name=$2
|
||||
dockerfile=$3
|
||||
build_arg=$4
|
||||
smoke=$5
|
||||
local_ref="bot-bottle-${name}:candidate"
|
||||
remote_ref="${registry}/bot-bottle-${name}:commit-${sha}"
|
||||
docker build --build-arg "$build_arg" -t "$local_ref" \
|
||||
-f "$dockerfile" .
|
||||
smoke_command=$(printf "$smoke" "$local_ref")
|
||||
sh -c "docker run --rm $smoke_command"
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
--build-arg "$build_arg" --push --iidfile "${key}.iid" \
|
||||
-t "$remote_ref" -f "$dockerfile" .
|
||||
digest=$(cat "${key}.iid")
|
||||
case "$digest" in sha256:*) ;; *) exit 1 ;; esac
|
||||
echo "${key}=${registry}/bot-bottle-${name}@${digest}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
build_image orchestrator orchestrator Dockerfile.orchestrator \
|
||||
"PYTHON_BASE_IMAGE=$python_base" \
|
||||
"--entrypoint python3 %s -c 'import bot_bottle.orchestrator'"
|
||||
build_image gateway gateway Dockerfile.gateway \
|
||||
"PYTHON_BASE_IMAGE=$python_base" \
|
||||
"--entrypoint mitmdump %s --version"
|
||||
build_image agent_claude claude bot_bottle/contrib/claude/Dockerfile \
|
||||
"NODE_BASE_IMAGE=$node_base" "%s claude --version"
|
||||
build_image agent_codex codex bot_bottle/contrib/codex/Dockerfile \
|
||||
"NODE_BASE_IMAGE=$node_base" "%s codex --version"
|
||||
build_image agent_pi pi bot_bottle/contrib/pi/Dockerfile \
|
||||
"NODE_BASE_IMAGE=$node_base" "%s pi --version"
|
||||
|
||||
firecracker:
|
||||
needs: resolve
|
||||
runs-on: [self-hosted, kvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- name: Build and smoke-test Firecracker artifacts
|
||||
env:
|
||||
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
|
||||
run: |
|
||||
python3 cli.py backend status --backend=firecracker
|
||||
python3 -m bot_bottle.backend.firecracker.publish_infra \
|
||||
--output infra-candidate --reuse-published
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR="$PWD/infra-candidate" \
|
||||
python3 -m unittest discover -t . -s tests/integration -v
|
||||
- name: Publish tested Firecracker artifacts
|
||||
env:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
|
||||
run: |
|
||||
python3 -m bot_bottle.backend.firecracker.publish_infra \
|
||||
--publish-dir infra-candidate
|
||||
- name: Record Firecracker artifact identities
|
||||
run: |
|
||||
cp infra-candidate/orchestrator/version.txt fc-orchestrator-version
|
||||
cp infra-candidate/orchestrator/rootfs.ext4.gz.sha256 fc-orchestrator-sha
|
||||
cp infra-candidate/gateway/version.txt fc-gateway-version
|
||||
cp infra-candidate/gateway/rootfs.ext4.gz.sha256 fc-gateway-sha
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: firecracker-identities
|
||||
path: |
|
||||
fc-orchestrator-version
|
||||
fc-orchestrator-sha
|
||||
fc-gateway-version
|
||||
fc-gateway-sha
|
||||
|
||||
package:
|
||||
needs: [resolve, oci, firecracker]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-identities
|
||||
- name: Build the wheel with the published identities
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages build
|
||||
python3 scripts/generate_release_manifest.py \
|
||||
--source-commit '${{ needs.resolve.outputs.sha }}' \
|
||||
--orchestrator-image '${{ needs.oci.outputs.orchestrator }}' \
|
||||
--gateway-image '${{ needs.oci.outputs.gateway }}' \
|
||||
--agent-claude-image '${{ needs.oci.outputs.agent_claude }}' \
|
||||
--agent-codex-image '${{ needs.oci.outputs.agent_codex }}' \
|
||||
--agent-pi-image '${{ needs.oci.outputs.agent_pi }}' \
|
||||
--firecracker-orchestrator-version "$(cat fc-orchestrator-version)" \
|
||||
--firecracker-orchestrator-sha256 "$(awk '{print $1}' fc-orchestrator-sha)" \
|
||||
--firecracker-gateway-version "$(cat fc-gateway-version)" \
|
||||
--firecracker-gateway-sha256 "$(awk '{print $1}' fc-gateway-sha)" \
|
||||
--output release-manifest.json
|
||||
BOT_BOTTLE_RELEASE_MANIFEST="$PWD/release-manifest.json" \
|
||||
python3 -m build --wheel
|
||||
- name: Verify and publish the immutable commit bundle
|
||||
env:
|
||||
BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
|
||||
run: |
|
||||
wheel=$(find dist -maxdepth 1 -name '*.whl' -type f)
|
||||
python3 -m venv verify-venv
|
||||
verify-venv/bin/pip install --no-deps "$wheel"
|
||||
verify-venv/bin/python -c \
|
||||
'from bot_bottle.release_manifest import load_manifest; load_manifest()'
|
||||
python3 scripts/generate_release_bundle.py \
|
||||
--manifest release-manifest.json \
|
||||
--wheel "$wheel" \
|
||||
--workflow-run \
|
||||
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--published-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--output bundle-index.json --publish
|
||||
@@ -0,0 +1,93 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
channel: ${{ steps.release.outputs.channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: release
|
||||
name: Validate tag shape and promotion branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag=${GITHUB_REF#refs/tags/}
|
||||
channel=$(python3 -c \
|
||||
'import sys; from bot_bottle.release_qualification import release_channel; print(release_channel(sys.argv[1]))' \
|
||||
"$tag")
|
||||
branch=$channel
|
||||
git fetch origin "$branch"
|
||||
sha=$(git rev-list -n 1 "$tag")
|
||||
git merge-base --is-ancestor "$sha" "origin/$branch" || {
|
||||
echo "$tag is not reachable from protected $branch" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=$channel" >> "$GITHUB_OUTPUT"
|
||||
|
||||
qualify:
|
||||
needs: validate
|
||||
uses: ./.gitea/workflows/pre-release-test.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.sha }}
|
||||
secrets:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
|
||||
artifacts:
|
||||
needs: [validate, qualify]
|
||||
uses: ./.gitea/workflows/publish-artifacts.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.sha }}
|
||||
secrets:
|
||||
BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
|
||||
promote:
|
||||
needs: [validate, artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-channel-${{ needs.validate.outputs.channel }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.sha }}
|
||||
- name: Verify install from the published bundle
|
||||
env:
|
||||
BOT_BOTTLE_REF: ${{ needs.validate.outputs.sha }}
|
||||
run: |
|
||||
sh install.sh
|
||||
"$HOME/.local/bin/bot-bottle" --help
|
||||
- name: Publish release qualification and advance channel
|
||||
env:
|
||||
BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/publish_release_qualification.py \
|
||||
--tag '${{ needs.validate.outputs.tag }}' \
|
||||
--source-commit '${{ needs.validate.outputs.sha }}' \
|
||||
--workflow-run \
|
||||
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--qualified-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
- name: Create the Gitea release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }}
|
||||
run: |
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${{ needs.validate.outputs.tag }}\",\"name\":\"${{ needs.validate.outputs.tag }}\",\"target_commitish\":\"${{ needs.validate.outputs.sha }}\"}" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
@@ -15,7 +15,7 @@
|
||||
## Features
|
||||
|
||||
- **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.
|
||||
- **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 `bot-bottle 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 gateway; the agent dials `http://gateway: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.
|
||||
@@ -39,7 +39,7 @@ On the legacy Docker backend, the same logical bottle is two containers per agen
|
||||
The Docker topology looks like this:
|
||||
|
||||
```
|
||||
host ( ./cli.py )
|
||||
host ( bot-bottle )
|
||||
│
|
||||
starts │ stops
|
||||
▼
|
||||
@@ -71,9 +71,32 @@ When the agent exits, `cli.py` tears down every gateway and both networks; nothi
|
||||
|
||||
## Quickstart
|
||||
|
||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. 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`.
|
||||
```sh
|
||||
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||
The installer is a bootstrapper: it finds a suitable Python, resolves the
|
||||
latest qualified production bundle, verifies its wheel checksum, installs with
|
||||
`pipx` (falling back to a private venv), creates `~/.bot-bottle`, and runs
|
||||
`bot-bottle doctor`. It is idempotent and never uses `sudo`.
|
||||
|
||||
Select staging with `BOT_BOTTLE_CHANNEL=staging`, an exact qualified release
|
||||
with `BOT_BOTTLE_VERSION=vX.Y.Z[-rc.N]`, or an exact published commit with
|
||||
`BOT_BOTTLE_REF=<40-character-sha>`. Commit installs print an explicit warning
|
||||
because snapshots may not have passed release qualification.
|
||||
|
||||
### Requirements
|
||||
|
||||
**Python ≥ 3.11**, and this is the one that trips people up on macOS: the `python3` Apple ships at `/usr/bin/python3` is **3.9.6**, which is too old. Bare `python3` resolves to that stub far more often than people expect. `path_helper` builds a login shell's `PATH` from `/etc/paths` and then appends `/etc/paths.d/*`, and `/usr/bin` sits in the former — so even when `/opt/homebrew/bin` *is* on the `PATH` (via `/etc/paths.d/homebrew`), it comes after `/usr/bin` and loses. Prepending a newer Python is something your shell profile does, and a fresh account, a launchd job, or a CI runner has no such profile. So the installer looks past bare `python3` before giving up: it tries `python3`, then the versioned `python3.11`–`python3.14` names, then `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, and python.org framework builds — and tells you which one it picked when it isn't the obvious one. Point it somewhere specific with `BOT_BOTTLE_PYTHON=/path/to/python3`.
|
||||
|
||||
**No `pipx` required.** If `pipx` is present the installer uses it and stays out of the way. If it isn't, bot-bottle installs into a private venv at `~/.bot-bottle/venv` (override with `BOT_BOTTLE_VENV`) and symlinks the entry point into `~/.local/bin`. There is deliberately no `pip install --user` path: Homebrew, python.org and Debian/Ubuntu interpreters are all externally managed (PEP 668), which blocks `--user` outright — so on a Mac it is never the fallback it appears to be. A venv is exempt from PEP 668, and `venv` is stdlib, so unlike `pipx` there is nothing to bootstrap first.
|
||||
|
||||
**`git`** is needed only when `BOT_BOTTLE_INSTALL_SPEC` explicitly selects a
|
||||
`git+` URL. The normal published-wheel path does not require it.
|
||||
|
||||
**A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. 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 bot-bottle start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||
|
||||
> **CI (macOS Apple Container):** the advisory `integration-macos` job in `.gitea/workflows/pre-release-test.yml` runs only on manual dispatch. It targets a self-hosted host-mode runner labelled `macos`; Apple Container cannot run inside the Linux pull-request runner. Provision an Apple Silicon host with the `container` CLI running and Python ≥ 3.11 plus `coverage` on the launchd service's explicit `PATH`. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1. Its coverage is reported separately and never feeds the required pull-request gate.
|
||||
|
||||
@@ -166,10 +189,10 @@ On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
|
||||
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
||||
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
|
||||
- **Docker** for the gateway and image build.
|
||||
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
|
||||
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `bot-bottle backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `bot-bottle backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
|
||||
|
||||
```sh
|
||||
BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
||||
BOT_BOTTLE_BACKEND=firecracker bot-bottle start <agent>
|
||||
```
|
||||
|
||||
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
|
||||
@@ -177,9 +200,19 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
||||
> **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`.
|
||||
|
||||
```sh
|
||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||
bot-bottle start <agent> # prepares the selected agent image, then attaches
|
||||
```
|
||||
|
||||
Packaged releases carry immutable orchestrator, gateway, and first-party agent
|
||||
image identities. Docker and Apple Container pull the package-selected OCI
|
||||
digests; Firecracker pulls the matching checksum-verified rootfs artifacts.
|
||||
Each verified wheel and its identities are published as an immutable generic
|
||||
package keyed by the full source commit; `bundle-index.json` is uploaded last
|
||||
and is the completeness marker. A source checkout uses local builds for
|
||||
development. Set `BOT_BOTTLE_INFRA_BUILD=local` to make that development
|
||||
override explicit when diagnosing a packaged release; production never falls
|
||||
back to a build after an artifact pull fails.
|
||||
|
||||
## Manifest
|
||||
|
||||
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
|
||||
@@ -253,7 +286,7 @@ You help maintain Gitea-hosted projects.
|
||||
| `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.
|
||||
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 `bot-bottle 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`.
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ class AgentProvider(ABC):
|
||||
initial task in a non-interactive (headless) session.
|
||||
|
||||
Called only when ``--prompt`` is passed to
|
||||
``./cli.py start --headless``; the returned args are appended
|
||||
``bot-bottle start --headless``; the returned args are appended
|
||||
after the provider's ``bypass_args`` and ``startup_args``."""
|
||||
|
||||
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
||||
|
||||
@@ -172,6 +172,10 @@ class BottleCleanupPlan(ABC):
|
||||
"""True iff there is nothing to clean up; the CLI uses this to
|
||||
short-circuit before showing the y/N."""
|
||||
|
||||
@abstractmethod
|
||||
def intersect(self, current: "BottleCleanupPlan") -> "BottleCleanupPlan":
|
||||
"""Resources both displayed to the operator and currently removable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecResult:
|
||||
@@ -527,7 +531,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
host-appropriate config or commands. Prints to stdout/stderr and
|
||||
returns a shell exit code (0 = nothing to report / success). A
|
||||
backend that needs no host setup prints a short note and returns
|
||||
0. Invoked generically by `./cli.py backend setup [--backend=…]`
|
||||
0. Invoked generically by `bot-bottle backend setup [--backend=…]`
|
||||
so operators can provision any backend without a
|
||||
backend-specific command. Classmethod (like `is_available`) —
|
||||
it's a host query, not per-bottle state."""
|
||||
@@ -544,14 +548,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
stderr. When quiet=True returns the status code silently —
|
||||
useful for cheap programmatic checks.
|
||||
|
||||
Invoked by `./cli.py backend status [--backend=…]` (quiet=False)
|
||||
Invoked by `bot-bottle backend status [--backend=…]` (quiet=False)
|
||||
and by is_backend_ready() (caller-controlled)."""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def teardown(cls) -> int:
|
||||
"""Undo `setup()` — the inverse operation, surfaced as
|
||||
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
|
||||
`bot-bottle backend teardown [--backend=…]` (uninstall). Symmetric
|
||||
with setup: where setup is advisory (prints the privileged
|
||||
commands / declarative config to apply), teardown prints the
|
||||
commands / config change to remove the host prerequisites. A
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Shared destructive-cleanup execution and failure accounting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CleanupError(RuntimeError):
|
||||
"""One or more approved cleanup mutations did not complete."""
|
||||
|
||||
|
||||
class CleanupFailures:
|
||||
"""Attempt every approved mutation, then fail with complete diagnostics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._messages: list[str] = []
|
||||
|
||||
def run(self, argv: Sequence[str], description: str) -> None:
|
||||
raw_timeout = os.environ.get(
|
||||
"BOT_BOTTLE_CLEANUP_COMMAND_TIMEOUT_SECONDS", "120",
|
||||
)
|
||||
try:
|
||||
timeout = float(raw_timeout)
|
||||
except ValueError:
|
||||
timeout = 120.0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
list(argv), capture_output=True, text=True, check=False,
|
||||
timeout=max(timeout, 1.0),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
self._messages.append(f"{description}: {exc}")
|
||||
return
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
self._messages.append(
|
||||
f"{description}: {detail or f'exit {result.returncode}'}"
|
||||
)
|
||||
|
||||
def remove_tree(self, path: Path, description: str) -> None:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as exc:
|
||||
self._messages.append(f"{description}: {exc}")
|
||||
|
||||
def record(self, message: str) -> None:
|
||||
self._messages.append(message)
|
||||
|
||||
def raise_if_any(self) -> None:
|
||||
if self._messages:
|
||||
raise CleanupError("; ".join(self._messages))
|
||||
|
||||
|
||||
__all__ = ["CleanupError", "CleanupFailures"]
|
||||
@@ -46,6 +46,22 @@ class DockerBottleCleanupPlan(BottleCleanupPlan):
|
||||
and not self.orphan_state_dirs
|
||||
)
|
||||
|
||||
def intersect(self, current: BottleCleanupPlan) -> "DockerBottleCleanupPlan":
|
||||
if not isinstance(current, DockerBottleCleanupPlan):
|
||||
raise TypeError("cleanup plans must have the same backend type")
|
||||
return DockerBottleCleanupPlan(
|
||||
projects=tuple(x for x in self.projects if x in current.projects),
|
||||
stray_containers=tuple(
|
||||
x for x in self.stray_containers if x in current.stray_containers
|
||||
),
|
||||
stray_networks=tuple(
|
||||
x for x in self.stray_networks if x in current.stray_networks
|
||||
),
|
||||
orphan_state_dirs=tuple(
|
||||
x for x in self.orphan_state_dirs if x in current.orphan_state_dirs
|
||||
),
|
||||
)
|
||||
|
||||
def print(self) -> None:
|
||||
print(file=sys.stderr)
|
||||
for name in self.projects:
|
||||
|
||||
@@ -23,12 +23,12 @@ Active-agent enumeration lives in `backend/docker/enumerate.py`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from ...paths import bot_bottle_root
|
||||
from ...log import info, warn
|
||||
from ...log import info
|
||||
from .. import EnumerationError
|
||||
from ..cleanup_control import CleanupFailures
|
||||
from . import util as docker_mod
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from ...bottle_state import bottle_state_dir, is_preserved
|
||||
@@ -150,40 +150,30 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
||||
"""Remove everything in the plan. Projects first (whose `compose
|
||||
down` reaps their containers + networks atomically), then stray
|
||||
legacy resources, then orphan state dirs."""
|
||||
failures = CleanupFailures()
|
||||
for project in plan.projects:
|
||||
info(f"docker compose down ({project})")
|
||||
result = subprocess.run(
|
||||
failures.run(
|
||||
["docker", "compose", "-p", project, "down", "--volumes"],
|
||||
capture_output=True, text=True, check=False,
|
||||
f"docker compose down failed for {project}",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(
|
||||
f"compose down failed for {project}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
|
||||
for name in plan.stray_containers:
|
||||
info(f"removing stray container {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"removing stray container {name}",
|
||||
)
|
||||
|
||||
for name in plan.stray_networks:
|
||||
info(f"removing stray network {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["docker", "network", "rm", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"removing stray network {name}",
|
||||
)
|
||||
|
||||
for identity in plan.orphan_state_dirs:
|
||||
path = bottle_state_dir(identity)
|
||||
info(f"removing orphan state dir {path}")
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except OSError as e:
|
||||
warn(f"failed to remove {path}: {e}")
|
||||
failures.remove_tree(path, f"removing orphan state dir {path}")
|
||||
failures.raise_if_any()
|
||||
|
||||
@@ -74,10 +74,13 @@ def list_compose_projects(
|
||||
result = subprocess.run(
|
||||
argv, capture_output=True, text=True, check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
except OSError as exc:
|
||||
# Not only "not found": docker on PATH but not executable by this
|
||||
# user raises PermissionError. Either way the query never ran, so an
|
||||
# enumeration caller must not read the empty result as authoritative.
|
||||
if raise_on_error:
|
||||
raise EnumerationError(
|
||||
"docker compose ls failed: docker not found"
|
||||
f"docker compose ls failed: docker unavailable ({exc})"
|
||||
) from exc
|
||||
return []
|
||||
if result.returncode != 0:
|
||||
|
||||
@@ -74,8 +74,9 @@ def _query_services_by_project() -> dict[str, set[str]]:
|
||||
],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise EnumerationError("docker ps failed: docker not found") from exc
|
||||
except OSError as exc:
|
||||
# Missing, or on PATH but not executable by this user (PermissionError).
|
||||
raise EnumerationError(f"docker ps failed: docker unavailable ({exc})") from exc
|
||||
if r.returncode != 0:
|
||||
raise EnumerationError(f"docker ps failed: {r.stderr.strip()}")
|
||||
return _parse_services_by_project(r.stdout or "")
|
||||
|
||||
@@ -89,6 +89,10 @@ class DockerGateway(Gateway):
|
||||
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
|
||||
forces a full rebuild (parity with `start --no-cache`)."""
|
||||
if self._dockerfile is None:
|
||||
proc = run_docker(["docker", "pull", self.image_ref])
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(
|
||||
f"gateway image pull failed: {proc.stderr.strip()}")
|
||||
return
|
||||
context = self._build_context or resources.build_root()
|
||||
argv = ["docker", "build", "-t", self.image_ref,
|
||||
|
||||
@@ -34,6 +34,7 @@ from .orchestrator import (
|
||||
ORCHESTRATOR_NETWORK,
|
||||
)
|
||||
from ... import resources
|
||||
from ... import release_manifest
|
||||
from ...gateway import (
|
||||
GATEWAY_IMAGE,
|
||||
GATEWAY_NAME,
|
||||
@@ -90,6 +91,14 @@ class DockerInfraService(InfraService):
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._orchestrator_label = orchestrator_label
|
||||
self._gateway_name = gateway_name
|
||||
resolved_orchestrator, orchestrator_local = release_manifest.oci_image(
|
||||
"orchestrator", orchestrator_image)
|
||||
resolved_gateway, gateway_local = release_manifest.oci_image(
|
||||
"gateway", gateway_image)
|
||||
self.orchestrator_image = resolved_orchestrator
|
||||
self.gateway_image = resolved_gateway
|
||||
self._orchestrator_local = orchestrator_local
|
||||
self._gateway_local = gateway_local
|
||||
|
||||
def orchestrator(self) -> DockerOrchestrator:
|
||||
"""The control-plane service. Cheap to reconstruct — `ensure_built`
|
||||
@@ -104,6 +113,8 @@ class DockerInfraService(InfraService):
|
||||
repo_root=self._repo_root,
|
||||
host_root=self._host_root,
|
||||
root_mount_source=self._root_mount_source,
|
||||
dockerfile=(
|
||||
"Dockerfile.orchestrator" if self._orchestrator_local else None),
|
||||
)
|
||||
|
||||
def gateway(self) -> DockerGateway:
|
||||
@@ -119,6 +130,7 @@ class DockerInfraService(InfraService):
|
||||
control_network=self.control_network,
|
||||
build_context=self._repo_root,
|
||||
ca_mount_source=self._gateway_ca_mount_source,
|
||||
dockerfile="Dockerfile.gateway" if self._gateway_local else None,
|
||||
)
|
||||
|
||||
def ensure_running(
|
||||
@@ -132,8 +144,8 @@ class DockerInfraService(InfraService):
|
||||
gateway = self.gateway()
|
||||
# Build both images (cache-aware; a no-op when nothing changed) before
|
||||
# bringing either plane up.
|
||||
orchestrator.ensure_built()
|
||||
gateway.ensure_built()
|
||||
orchestrator.ensure_available()
|
||||
gateway.ensure_available()
|
||||
|
||||
orchestrator.ensure_running(startup_timeout=startup_timeout)
|
||||
|
||||
|
||||
@@ -84,6 +84,15 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return BottleImages(agent=plan.image)
|
||||
if "@sha256:" in plan.image:
|
||||
pulled = docker_mod.run_docker(["docker", "pull", plan.image])
|
||||
if pulled.returncode != 0:
|
||||
die(f"pulling packaged agent image {plan.image!r} failed: "
|
||||
f"{pulled.stderr.strip()}")
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
return BottleImages(agent=plan.image)
|
||||
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
|
||||
@@ -125,6 +125,10 @@ class DockerOrchestrator(Orchestrator):
|
||||
no-op when nothing changed). No-op when no dockerfile is configured (a
|
||||
pre-pulled image). BOT_BOTTLE_NO_CACHE forces a full rebuild."""
|
||||
if self._dockerfile is None:
|
||||
proc = run_docker(["docker", "pull", self.image_ref])
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(
|
||||
f"orchestrator image pull failed: {proc.stderr.strip()}")
|
||||
return
|
||||
argv = ["docker", "build", "-t", self.image_ref,
|
||||
"-f", str(self._repo_root / self._dockerfile),
|
||||
|
||||
@@ -8,7 +8,7 @@ pointer, and `status()` reports whether docker is usable.
|
||||
This is intentionally minimal; a richer version (daemon config checks,
|
||||
gVisor/runsc install guidance, rootless-docker hints) is tracked
|
||||
separately. Reached via `DockerBottleBackend.setup` / `.status`, which
|
||||
the generic `./cli.py backend {setup,status}` dispatches to.
|
||||
the generic `bot-bottle backend {setup,status}` dispatches to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -69,7 +69,7 @@ def teardown() -> int:
|
||||
sys.stderr.write(
|
||||
"Docker backend: nothing to undo — it provisions no privileged host "
|
||||
"state (networks and the gateway are per-launch and are "
|
||||
"removed by `./cli.py cleanup`). Docker itself is left installed.\n"
|
||||
"removed by `bot-bottle cleanup`). Docker itself is left installed.\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -89,5 +89,5 @@ def status() -> int:
|
||||
runsc = _docker_on_path() and _util.runsc_available()
|
||||
sys.stderr.write(f"gVisor runsc runtime: {'registered' if runsc else 'not registered (optional)'}\n")
|
||||
if not ok:
|
||||
sys.stderr.write("\nRun: ./cli.py backend setup --backend=docker\n")
|
||||
sys.stderr.write("\nRun: bot-bottle backend setup --backend=docker\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
@@ -27,3 +27,11 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not (self.vm_pids or self.run_dirs)
|
||||
|
||||
def intersect(self, current: BottleCleanupPlan) -> "FirecrackerBottleCleanupPlan":
|
||||
if not isinstance(current, FirecrackerBottleCleanupPlan):
|
||||
raise TypeError("cleanup plans must have the same backend type")
|
||||
return FirecrackerBottleCleanupPlan(
|
||||
vm_pids=tuple(x for x in self.vm_pids if x in current.vm_pids),
|
||||
run_dirs=tuple(x for x in self.run_dirs if x in current.run_dirs),
|
||||
)
|
||||
|
||||
@@ -23,13 +23,13 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import info
|
||||
from .. import EnumerationError
|
||||
from ..cleanup_control import CleanupError, CleanupFailures
|
||||
from . import lifecycle_lock, util
|
||||
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||
|
||||
@@ -151,11 +151,16 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
||||
fresh = prepare_cleanup()
|
||||
approved_pids = set(plan.vm_pids).intersection(fresh.vm_pids)
|
||||
approved_dirs = set(plan.run_dirs).intersection(fresh.run_dirs)
|
||||
failures = CleanupFailures()
|
||||
for pid in sorted(approved_pids):
|
||||
_terminate_orphan(pid, _run_root())
|
||||
try:
|
||||
_terminate_orphan(pid, _run_root())
|
||||
except CleanupError as exc:
|
||||
failures.record(str(exc))
|
||||
for path in sorted(approved_dirs):
|
||||
info(f"rm -rf {path}")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
failures.remove_tree(Path(path), f"removing Firecracker run dir {path}")
|
||||
failures.raise_if_any()
|
||||
|
||||
|
||||
def _terminate_orphan(pid: int, run_root: Path) -> None:
|
||||
@@ -182,6 +187,13 @@ def _terminate_orphan(pid: int, run_root: Path) -> None:
|
||||
if run_dir is None or run_dir.is_dir():
|
||||
return
|
||||
info(f"kill firecracker VM pid {pid}")
|
||||
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
|
||||
try:
|
||||
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError as exc:
|
||||
raise CleanupError(
|
||||
f"could not signal Firecracker pid {pid}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
|
||||
@@ -72,6 +72,46 @@ def cached_agent_rootfs_dir(dockerfile: Path) -> Path | None:
|
||||
return base if (base / ".bb-ready").is_file() else None
|
||||
|
||||
|
||||
def _image_rootfs_digest(image: str) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(image.encode())
|
||||
h.update(b"\0")
|
||||
h.update(util._GUEST_INIT.encode())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def cached_agent_image_rootfs_dir(image: str) -> Path | None:
|
||||
"""Return a ready rootfs exported from an immutable OCI image."""
|
||||
base = util.cache_dir() / "rootfs" / f"agent-image-{_image_rootfs_digest(image)}"
|
||||
return base if (base / ".bb-ready").is_file() else None
|
||||
|
||||
|
||||
def acquire_agent_image_rootfs_dir(
|
||||
image: str, *, smoke_test: tuple[str, ...] = (),
|
||||
) -> Path:
|
||||
"""Pull a digest-pinned image in the infra VM and export its rootfs."""
|
||||
if "@sha256:" not in image:
|
||||
die(f"prebuilt Firecracker agent image is not digest-pinned: {image}")
|
||||
digest = _image_rootfs_digest(image)
|
||||
base = util.cache_dir() / "rootfs" / f"agent-image-{digest}"
|
||||
cached = cached_agent_image_rootfs_dir(image)
|
||||
if cached is not None:
|
||||
info(f"using cached agent rootfs {cached.name}")
|
||||
return cached
|
||||
with _build_lock():
|
||||
if (base / ".bb-ready").is_file():
|
||||
return base
|
||||
staging = util.cache_dir() / "rootfs" / f".pulling-{digest}"
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True)
|
||||
_pull_in_infra(image, staging, smoke_test, digest)
|
||||
util.inject_guest_boot(staging)
|
||||
(staging / ".bb-ready").write_text("ok\n")
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
os.rename(staging, base)
|
||||
return base
|
||||
|
||||
|
||||
def build_agent_rootfs_dir(
|
||||
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
|
||||
) -> Path:
|
||||
@@ -167,6 +207,43 @@ def _build_in_infra(
|
||||
_cleanup()
|
||||
|
||||
|
||||
def _pull_in_infra(
|
||||
image: str, base: Path, smoke_test: tuple[str, ...], digest: str,
|
||||
) -> None:
|
||||
"""Pull and export a published agent image inside the orchestrator VM."""
|
||||
service = FirecrackerInfraService()
|
||||
service.ensure_running()
|
||||
key, ip = service.orchestrator().ssh_target()
|
||||
tag = f"bot-bottle-agent-pull-{digest}"
|
||||
smoke_ctr, export_ctr = f"{tag}-smoke", f"{tag}-export"
|
||||
quoted_image = shlex.quote(image)
|
||||
|
||||
def cleanup() -> None:
|
||||
_ssh(
|
||||
key,
|
||||
ip,
|
||||
f"buildah rm {smoke_ctr} {export_ctr} >/dev/null 2>&1; "
|
||||
f"buildah rmi {_STORE_FLAG} {tag} >/dev/null 2>&1",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
cleanup()
|
||||
try:
|
||||
result = _ssh(
|
||||
key,
|
||||
ip,
|
||||
f"buildah pull {_STORE_FLAG} {quoted_image} && "
|
||||
f"buildah tag {_STORE_FLAG} {quoted_image} {tag}",
|
||||
timeout=_BUILD_TIMEOUT_SECONDS,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"pulling pinned agent image failed: {result.stderr.strip()}")
|
||||
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
|
||||
_stream_rootfs(key, ip, tag, export_ctr, base)
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
|
||||
def _ssh(private_key: Path, guest_ip: str, script: str,
|
||||
*, timeout: float = 60.0) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
|
||||
@@ -61,7 +61,7 @@ class FirecrackerInfraService(InfraService):
|
||||
return url
|
||||
# Clear stale/hung/OUTDATED VMs holding either link before booting fresh.
|
||||
infra_vm.stop()
|
||||
infra_vm.ensure_built()
|
||||
infra_vm.ensure_available()
|
||||
# Orchestrator first — the gateway daemons reach the control plane at
|
||||
# startup. Each service owns its own boot; the orchestrator (which
|
||||
# holds the signing key) mints the role-scoped `gateway` JWT for the
|
||||
|
||||
@@ -195,7 +195,9 @@ def _sha256_file(path: Path) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def ensure_artifact_gz(version: str, *, role: str) -> Path:
|
||||
def ensure_artifact_gz(
|
||||
version: str, *, role: str, expected_sha256: str | None = None,
|
||||
) -> Path:
|
||||
"""The verified, cached `rootfs.ext4.gz` for `role` at `version` —
|
||||
downloading it (and its `.sha256`) once, then reusing it. Fail-closed on a
|
||||
checksum mismatch: the partial is removed and we die rather than boot an
|
||||
@@ -222,6 +224,11 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
|
||||
if not gz.is_file() or not sha.is_file():
|
||||
die(f"infra candidate bundle is incomplete: {root}")
|
||||
expected = sha.read_text().split()[0].strip().lower()
|
||||
if expected_sha256 is not None and expected != expected_sha256:
|
||||
die(
|
||||
f"infra candidate packaged checksum mismatch ({role}) for {version}:\n"
|
||||
f" packaged {expected_sha256}\n candidate {expected}"
|
||||
)
|
||||
actual = _sha256_file(gz)
|
||||
if actual != expected:
|
||||
die(
|
||||
@@ -235,7 +242,13 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
|
||||
gz = root / _GZ_NAME
|
||||
ok = root / ".verified"
|
||||
if gz.is_file() and ok.is_file():
|
||||
return gz
|
||||
actual = _sha256_file(gz)
|
||||
recorded = ok.read_text(encoding="utf-8").strip()
|
||||
wanted = expected_sha256 or recorded
|
||||
if actual == recorded == wanted:
|
||||
return gz
|
||||
gz.unlink(missing_ok=True)
|
||||
ok.unlink(missing_ok=True)
|
||||
|
||||
info(f"pulling infra rootfs artifact {_package(role)}/{version}")
|
||||
_download(artifact_url(version, _GZ_NAME, role=role), gz)
|
||||
@@ -243,6 +256,14 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
|
||||
_download(artifact_url(version, _SHA_NAME, role=role), sha)
|
||||
|
||||
expected = sha.read_text().split()[0].strip().lower()
|
||||
if expected_sha256 is not None and expected != expected_sha256:
|
||||
gz.unlink(missing_ok=True)
|
||||
sha.unlink(missing_ok=True)
|
||||
die(
|
||||
f"infra artifact published checksum mismatch ({role}) for {version}:\n"
|
||||
f" packaged {expected_sha256}\n"
|
||||
f" published {expected}"
|
||||
)
|
||||
actual = _sha256_file(gz)
|
||||
if actual != expected:
|
||||
gz.unlink(missing_ok=True)
|
||||
@@ -253,15 +274,22 @@ def ensure_artifact_gz(version: str, *, role: str) -> Path:
|
||||
f" actual {actual}\n"
|
||||
f" refusing to boot an unverified rootfs."
|
||||
)
|
||||
ok.write_text("ok\n")
|
||||
ok.write_text(actual + "\n")
|
||||
return gz
|
||||
|
||||
|
||||
def materialize_ext4(version: str, dest: Path, *, role: str) -> None:
|
||||
def materialize_ext4(
|
||||
version: str,
|
||||
dest: Path,
|
||||
*,
|
||||
role: str,
|
||||
expected_sha256: str | None = None,
|
||||
) -> None:
|
||||
"""Ensure the verified `role` artifact is cached, then gunzip it to `dest` —
|
||||
a fresh, writable per-boot rootfs (the VM mutates it; the cached `.gz` stays
|
||||
pristine). Atomic via a `.part` sibling."""
|
||||
gz = ensure_artifact_gz(version, role=role)
|
||||
gz = ensure_artifact_gz(
|
||||
version, role=role, expected_sha256=expected_sha256)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
info(f"expanding {role} infra rootfs -> {dest}")
|
||||
with gzip.open(gz, "rb") as src, open(tmp, "wb") as out:
|
||||
|
||||
@@ -43,6 +43,7 @@ from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ... import resources
|
||||
from ... import release_manifest
|
||||
from ...log import die, info
|
||||
from ..docker import util as docker_mod
|
||||
from . import firecracker_vm, infra_artifact, netpool, util
|
||||
@@ -108,6 +109,18 @@ def _role_version(role: str) -> str:
|
||||
return infra_artifact.infra_artifact_version(role_init(role), role)
|
||||
|
||||
|
||||
def _role_release(role: str) -> tuple[str, str | None]:
|
||||
manifest = release_manifest.packaged_manifest()
|
||||
if manifest is None or release_manifest.local_build_requested():
|
||||
return _role_version(role), None
|
||||
artifact = (
|
||||
manifest.firecracker_orchestrator
|
||||
if role == "orchestrator"
|
||||
else manifest.firecracker_gateway
|
||||
)
|
||||
return artifact.version, artifact.sha256
|
||||
|
||||
|
||||
def ensure_built() -> None:
|
||||
"""Ensure both infra rootfs artifacts are available before boot.
|
||||
|
||||
@@ -117,11 +130,16 @@ def ensure_built() -> None:
|
||||
`BOT_BOTTLE_INFRA_BUILD=local` instead builds the images from source with
|
||||
host Docker (the orchestrator-fc image is `FROM` the orchestrator image, so
|
||||
it must exist first) — for iterating on the Dockerfiles."""
|
||||
if infra_artifact.local_build_requested():
|
||||
if release_manifest.local_build_requested():
|
||||
build_infra_images_with_docker()
|
||||
return
|
||||
for role in infra_artifact.ROLES:
|
||||
infra_artifact.ensure_artifact_gz(_role_version(role), role=role)
|
||||
version, expected = _role_release(role)
|
||||
infra_artifact.ensure_artifact_gz(
|
||||
version, role=role, expected_sha256=expected)
|
||||
|
||||
|
||||
ensure_available = ensure_built
|
||||
|
||||
|
||||
def build_infra_images_with_docker() -> None:
|
||||
@@ -204,7 +222,7 @@ def boot_vm(
|
||||
Records the PID."""
|
||||
if not netpool.tap_present(slot.iface):
|
||||
die(f"infra link {slot.iface} not present.\n"
|
||||
f" ./cli.py backend setup --backend=firecracker")
|
||||
f" bot-bottle backend setup --backend=firecracker")
|
||||
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
rootfs = run_dir / "rootfs.ext4"
|
||||
@@ -214,7 +232,9 @@ def boot_vm(
|
||||
else:
|
||||
# Prebuilt artifact already carries the role's build slack; expand it to
|
||||
# a fresh writable rootfs for this boot.
|
||||
infra_artifact.materialize_ext4(_role_version(role), rootfs, role=role)
|
||||
version, expected = _role_release(role)
|
||||
infra_artifact.materialize_ext4(
|
||||
version, rootfs, role=role, expected_sha256=expected)
|
||||
private_key, pubkey = _stable_keypair()
|
||||
|
||||
info(f"booting {role} VM on {slot.iface} (guest {slot.guest_ip})")
|
||||
@@ -264,7 +284,8 @@ def _version_file() -> Path:
|
||||
def expected_version() -> str:
|
||||
"""The combined marker for the running pair: both per-plane artifact
|
||||
versions, so a change to either rootfs dislodges the adopted pair."""
|
||||
return " ".join(f"{role}={_role_version(role)}" for role in infra_artifact.ROLES)
|
||||
return " ".join(
|
||||
f"{role}={_role_release(role)[0]}" for role in infra_artifact.ROLES)
|
||||
|
||||
|
||||
def adoptable(key: Path, url: str, want: str) -> bool:
|
||||
|
||||
@@ -108,7 +108,7 @@ def verify_isolation(private_key: Path, guest_ip: str) -> None:
|
||||
die(f"ISOLATION FAILURE: the VM reached the host canary "
|
||||
f"{canary_ip}:{canary_port}. The egress boundary is not in "
|
||||
f"force — refusing to run the agent (fail-closed). Verify the "
|
||||
f"nft table with: ./cli.py backend setup --backend=firecracker")
|
||||
f"nft table with: bot-bottle backend setup --backend=firecracker")
|
||||
if result.returncode == 2:
|
||||
die("isolation probe inconclusive: the guest has no python3/bash/nc "
|
||||
"to run the connectivity test. Refusing to continue "
|
||||
|
||||
@@ -228,8 +228,13 @@ def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
|
||||
info(f"resuming from committed rootfs {committed_tar}")
|
||||
return util.build_committed_rootfs_dir(committed_tar)
|
||||
dockerfile = Path(plan.dockerfile_path)
|
||||
prebuilt = "@sha256:" in plan.image
|
||||
if plan.spec.image_policy == "cached":
|
||||
cached = image_builder.cached_agent_rootfs_dir(dockerfile)
|
||||
cached = (
|
||||
image_builder.cached_agent_image_rootfs_dir(plan.image)
|
||||
if prebuilt
|
||||
else image_builder.cached_agent_rootfs_dir(dockerfile)
|
||||
)
|
||||
if cached is None:
|
||||
die(
|
||||
f"cached agent rootfs for {plan.image!r} not found; "
|
||||
@@ -237,6 +242,11 @@ def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
|
||||
)
|
||||
info(f"using cached agent rootfs {cached.name}")
|
||||
return cached
|
||||
if prebuilt:
|
||||
return image_builder.acquire_agent_image_rootfs_dir(
|
||||
plan.image,
|
||||
smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
return image_builder.build_agent_rootfs_dir(
|
||||
dockerfile,
|
||||
image_tag=plan.image,
|
||||
@@ -253,7 +263,11 @@ def stale_checks(plan: FirecrackerBottlePlan) -> None:
|
||||
if committed and committed_tar.is_file():
|
||||
check_stale_path(f"agent rootfs {committed_tar}", committed_tar)
|
||||
return
|
||||
cached = image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
|
||||
cached = (
|
||||
image_builder.cached_agent_image_rootfs_dir(plan.image)
|
||||
if "@sha256:" in plan.image
|
||||
else image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
|
||||
)
|
||||
if cached is not None:
|
||||
check_stale_path(f"agent rootfs {cached}", cached / ".bb-ready")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ config renderers (shell command + NixOS module) shown to operators.
|
||||
|
||||
The Firecracker backend needs a privileged one-time network setup:
|
||||
a pool of point-to-point TAP devices (owned by the invoking user, so
|
||||
`./cli.py start` never needs root) and a dedicated nftables table that
|
||||
`bot-bottle start` never needs root) and a dedicated nftables table that
|
||||
isolates every VM. The pool parameters live in exactly one place —
|
||||
`netpool.defaults.env`, a plain KEY=VALUE file next to this module —
|
||||
and every consumer reads *that*: this module (below), the shell script
|
||||
@@ -177,14 +177,20 @@ def gw_slot() -> Slot:
|
||||
# --- fail-closed verification ---------------------------------------
|
||||
|
||||
def _run_ok(argv: list[str]) -> bool:
|
||||
"""Run a probe command, treating a missing binary as failure
|
||||
"""Run a probe command, treating an unavailable binary as failure
|
||||
(rather than crashing) so callers can stay fail-closed."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0
|
||||
except FileNotFoundError:
|
||||
except OSError:
|
||||
# Not only "missing". A name on PATH that isn't executable by this
|
||||
# user raises PermissionError, and CPython reports that EACCES in
|
||||
# preference to the ENOENT from the other PATH entries — which is
|
||||
# how `doctor` came to die with a traceback on a fresh macOS
|
||||
# account. Any OSError means the probe couldn't run, which for a
|
||||
# fail-closed check is indistinguishable from "not present".
|
||||
return False
|
||||
|
||||
|
||||
@@ -244,7 +250,9 @@ def overlapping_routes() -> list[RouteConflict]:
|
||||
["ip", "-json", "route", "show", "table", "all"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
except OSError:
|
||||
# Missing, or present-but-not-executable for this user; either way
|
||||
# there are no routes we can enumerate. See _run_ok.
|
||||
return []
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
return []
|
||||
@@ -307,11 +315,11 @@ def allocate(slug: str) -> tuple[Slot, IO[str]]:
|
||||
return s, handle
|
||||
die(f"Firecracker TAP pool exhausted ({pool_size()} slots, all in "
|
||||
f"use). Stop a running bottle or raise BOT_BOTTLE_FC_POOL_SIZE "
|
||||
f"and re-run `./cli.py backend setup --backend=firecracker`.")
|
||||
f"and re-run `bot-bottle backend setup --backend=firecracker`.")
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
# --- config renderers (shown by `./cli.py backend setup`) -----------
|
||||
# --- config renderers (shown by `bot-bottle backend setup`) -----------
|
||||
|
||||
# The persistent unit is the portable install: the same systemd oneshot
|
||||
# on every systemd distro (Debian/Ubuntu/Fedora/RHEL/Arch/…).
|
||||
|
||||
@@ -8,7 +8,7 @@ bundled setup script. `status()` reports what's present, including
|
||||
whether the pool range collides with an existing route.
|
||||
|
||||
Called through `FirecrackerBottleBackend.setup` / `.status`, which the
|
||||
generic `./cli.py backend {setup,status}` command dispatches to.
|
||||
generic `bot-bottle backend {setup,status}` command dispatches to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,7 +34,7 @@ _UNIT_PATH = Path("/etc/systemd/system") / netpool.SYSTEMD_UNIT
|
||||
|
||||
def _owner() -> str:
|
||||
# Under `sudo`, USER is root but SUDO_USER is the real invoker — the
|
||||
# TAPs must be owned by them so `./cli.py start` stays rootless.
|
||||
# TAPs must be owned by them so `bot-bottle start` stays rootless.
|
||||
return os.environ.get("SUDO_USER") or os.environ.get("USER") or "youruser"
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ def _setup_systemd() -> None:
|
||||
if rc == 0:
|
||||
sys.stderr.write(
|
||||
f"Installed and started {netpool.SYSTEMD_UNIT}. Verify with "
|
||||
f"`./cli.py backend status --backend=firecracker`.\n"
|
||||
f"`bot-bottle backend status --backend=firecracker`.\n"
|
||||
)
|
||||
else:
|
||||
sys.stderr.write(
|
||||
@@ -181,7 +181,7 @@ def _setup_systemd() -> None:
|
||||
)
|
||||
sys.stderr.write(
|
||||
f"\n(Or re-run this as root to install it directly: "
|
||||
f"sudo ./cli.py backend setup --backend=firecracker)\n"
|
||||
f"sudo bot-bottle backend setup --backend=firecracker)\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ def status() -> int:
|
||||
sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n")
|
||||
_report_persistence()
|
||||
if not ok:
|
||||
sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n")
|
||||
sys.stderr.write("\nRun: bot-bottle backend setup --backend=firecracker\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ generation.
|
||||
|
||||
The privileged network setup (TAP pool + nft table) is a one-time
|
||||
operator step — see `netpool.py`, `scripts/firecracker-netpool.sh`,
|
||||
and `./cli.py backend setup --backend=firecracker`.
|
||||
and `bot-bottle backend setup --backend=firecracker`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -132,18 +132,18 @@ def _require_network_pool() -> None:
|
||||
f"{netpool.pool_size()} slots) overlaps existing routes: "
|
||||
f"{detail}. This can shadow or be shadowed by that route; "
|
||||
f"set BOT_BOTTLE_FC_IP_BASE to a free range and re-run "
|
||||
f"./cli.py backend setup --backend=firecracker.")
|
||||
f"bot-bottle backend setup --backend=firecracker.")
|
||||
missing = netpool.missing_taps()
|
||||
if missing:
|
||||
die(f"network pool incomplete — missing TAP devices: "
|
||||
f"{', '.join(missing)}.\n ./cli.py backend setup --backend=firecracker")
|
||||
f"{', '.join(missing)}.\n bot-bottle backend setup --backend=firecracker")
|
||||
if shutil.which("nft") is not None and not netpool.nft_table_present():
|
||||
# nft is queryable and says the table is absent — that's a
|
||||
# definite, catchable misconfiguration; fail early.
|
||||
warn(f"isolation table `inet {netpool.NFT_TABLE}` not found via nft. "
|
||||
"If this is a permissions issue it will be re-checked "
|
||||
"empirically after boot; otherwise run: "
|
||||
"./cli.py backend setup --backend=firecracker")
|
||||
"bot-bottle backend setup --backend=firecracker")
|
||||
|
||||
|
||||
# --- rootfs pipeline (rootless) -------------------------------------
|
||||
|
||||
@@ -43,7 +43,7 @@ class Freezer(ABC):
|
||||
|
||||
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.
|
||||
preserved so the next `bot-bottle resume` boots from the snapshot.
|
||||
|
||||
Raises CommitCancelled if the user declines an interactive
|
||||
confirmation prompt (e.g. the macos-container stop prompt).
|
||||
@@ -51,7 +51,7 @@ class Freezer(ABC):
|
||||
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}")
|
||||
info(f"to resume from this snapshot: bot-bottle resume {agent.slug}")
|
||||
self._export_hint(agent.slug, image_ref)
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -25,3 +25,11 @@ class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not self.containers and not self.networks
|
||||
|
||||
def intersect(self, current: BottleCleanupPlan) -> "MacosContainerBottleCleanupPlan":
|
||||
if not isinstance(current, MacosContainerBottleCleanupPlan):
|
||||
raise TypeError("cleanup plans must have the same backend type")
|
||||
return MacosContainerBottleCleanupPlan(
|
||||
containers=tuple(x for x in self.containers if x in current.containers),
|
||||
networks=tuple(x for x in self.networks if x in current.networks),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
|
||||
from .. import EnumerationError
|
||||
from ..cleanup_control import CleanupFailures
|
||||
from ...log import info
|
||||
from . import util as container_mod
|
||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||
@@ -53,19 +54,17 @@ def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
||||
|
||||
|
||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
||||
failures = CleanupFailures()
|
||||
for name in plan.containers:
|
||||
info(f"container delete --force {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["container", "delete", "--force", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"deleting container {name}",
|
||||
)
|
||||
for name in plan.networks:
|
||||
info(f"container network delete {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["container", "network", "delete", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"deleting network {name}",
|
||||
)
|
||||
failures.raise_if_any()
|
||||
|
||||
@@ -84,6 +84,7 @@ class MacosGateway(Gateway):
|
||||
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path | None = None,
|
||||
local_build: bool = True,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
@@ -93,6 +94,7 @@ class MacosGateway(Gateway):
|
||||
# Build context: the repo root in a checkout, a staged copy from the
|
||||
# installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._local_build = local_build
|
||||
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
|
||||
# against + the pre-minted `gateway` token they present. The gateway
|
||||
# never mints, so it never holds the signing key (#469).
|
||||
@@ -101,8 +103,11 @@ class MacosGateway(Gateway):
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Build the data-plane image from `Dockerfile.gateway`."""
|
||||
container_mod.build_image(
|
||||
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
|
||||
if self._local_build:
|
||||
container_mod.build_image(
|
||||
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
|
||||
else:
|
||||
container_mod.pull_image(self.image_ref)
|
||||
|
||||
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
|
||||
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ... import release_manifest
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
@@ -86,6 +87,14 @@ class MacosInfraService(InfraService):
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._gateway_name = gateway_name
|
||||
self._db_volume = db_volume
|
||||
resolved_orchestrator, orchestrator_local = release_manifest.oci_image(
|
||||
"orchestrator", orchestrator_image)
|
||||
resolved_gateway, gateway_local = release_manifest.oci_image(
|
||||
"gateway", gateway_image)
|
||||
self.orchestrator_image = resolved_orchestrator
|
||||
self.gateway_image = resolved_gateway
|
||||
self._orchestrator_local = orchestrator_local
|
||||
self._gateway_local = gateway_local
|
||||
|
||||
def orchestrator(self) -> MacosOrchestrator:
|
||||
"""The control-plane service on the host-only control network. Cheap to
|
||||
@@ -98,6 +107,7 @@ class MacosInfraService(InfraService):
|
||||
control_network=self.control_network,
|
||||
repo_root=self._repo_root,
|
||||
db_volume=self._db_volume,
|
||||
local_build=self._orchestrator_local,
|
||||
)
|
||||
|
||||
def gateway(self) -> MacosGateway:
|
||||
@@ -112,6 +122,7 @@ class MacosInfraService(InfraService):
|
||||
egress_network=self.egress_network,
|
||||
control_network=self.control_network,
|
||||
repo_root=self._repo_root,
|
||||
local_build=self._gateway_local,
|
||||
)
|
||||
|
||||
def ensure_running(
|
||||
@@ -127,8 +138,8 @@ class MacosInfraService(InfraService):
|
||||
|
||||
orchestrator = self.orchestrator()
|
||||
gateway = self.gateway()
|
||||
orchestrator.ensure_built()
|
||||
gateway.ensure_built()
|
||||
orchestrator.ensure_available()
|
||||
gateway.ensure_available()
|
||||
|
||||
orchestrator.ensure_running(startup_timeout=startup_timeout)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
@@ -93,7 +94,14 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return plan.image
|
||||
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
if "@sha256:" in plan.image:
|
||||
container_mod.pull_image(plan.image)
|
||||
container_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
return plan.image
|
||||
container_mod.build_image(
|
||||
plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
return plan.image
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class MacosOrchestrator(Orchestrator):
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path | None = None,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
local_build: bool = True,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
@@ -73,6 +74,7 @@ class MacosOrchestrator(Orchestrator):
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._db_volume = db_volume
|
||||
self._local_build = local_build
|
||||
|
||||
def url(self) -> str:
|
||||
"""The orchestrator's control-network address (host CLI + registration),
|
||||
@@ -114,8 +116,12 @@ class MacosOrchestrator(Orchestrator):
|
||||
"""Build the control-plane image. The source is bind-mounted so a code
|
||||
change takes effect without a rebuild; the image still carries the
|
||||
package for its entrypoint."""
|
||||
container_mod.build_image(
|
||||
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.orchestrator")
|
||||
if self._local_build:
|
||||
container_mod.build_image(
|
||||
self.image_ref, str(self._repo_root),
|
||||
dockerfile="Dockerfile.orchestrator")
|
||||
else:
|
||||
container_mod.pull_image(self.image_ref)
|
||||
|
||||
def ensure_running(
|
||||
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
@@ -146,11 +152,6 @@ class MacosOrchestrator(Orchestrator):
|
||||
"--dns", container_mod.dns_server(),
|
||||
# Container-only DB volume: exactly one kernel writes bot-bottle.db.
|
||||
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
|
||||
# Live control-plane source (a code change takes effect on relaunch).
|
||||
"--mount",
|
||||
container_mod.bind_mount_spec(
|
||||
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
|
||||
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
|
||||
"--env", f"BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER}",
|
||||
# Detect a real control-plane code change and recreate.
|
||||
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
|
||||
@@ -161,6 +162,14 @@ class MacosOrchestrator(Orchestrator):
|
||||
# Dockerfile.orchestrator ENTRYPOINT is `-m bot_bottle.orchestrator`.
|
||||
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
|
||||
]
|
||||
if self._local_build:
|
||||
image_index = argv.index(self.image_ref)
|
||||
argv[image_index:image_index] = [
|
||||
"--mount",
|
||||
container_mod.bind_mount_spec(
|
||||
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
|
||||
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
|
||||
]
|
||||
result = container_mod.run_container_argv(
|
||||
argv, env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
|
||||
if result.returncode != 0:
|
||||
|
||||
@@ -5,7 +5,7 @@ Like Docker, this backend needs no privileged network-pool provisioning
|
||||
running. `setup()` points at the install/`container system start` steps;
|
||||
`status()` reports readiness. Reached via
|
||||
`MacosContainerBottleBackend.setup` / `.status`, dispatched from the
|
||||
generic `./cli.py backend {setup,status}`.
|
||||
generic `bot-bottle backend {setup,status}`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -75,5 +75,5 @@ def status() -> int:
|
||||
if not _service_running():
|
||||
ok = False
|
||||
if not ok:
|
||||
sys.stderr.write("\nRun: ./cli.py backend setup --backend=macos-container\n")
|
||||
sys.stderr.write("\nRun: bot-bottle backend setup --backend=macos-container\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
@@ -101,6 +101,19 @@ def build_image(
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def pull_image(ref: str) -> None:
|
||||
"""Acquire an immutable OCI reference through Apple Container."""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "image", "pull", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(f"container image pull failed for {ref}: {detail}")
|
||||
|
||||
|
||||
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
||||
"""Run `argv` inside a throwaway container of a freshly built agent
|
||||
image and die loudly if it fails, instead of shipping an image
|
||||
@@ -689,6 +702,13 @@ def pinned_local_image_ref(ref: str) -> str:
|
||||
nested-containers layer. A content-derived tag prevents another concurrent
|
||||
build from moving the provider's ordinary ``:latest`` tag between those
|
||||
two builds.
|
||||
|
||||
Unlike Docker, `container image tag` only accepts ``image-name[:tag]`` as
|
||||
its source and rejects a bare image ID ("cannot specify 64 byte hex string
|
||||
as reference"), so the mutable ``ref`` is what gets tagged here. The
|
||||
post-tag inspection below is what keeps that fail-closed: if ``ref`` moved
|
||||
between the two commands, the new tag will not resolve to the ID this call
|
||||
derived its name from.
|
||||
"""
|
||||
image = image_id(ref)
|
||||
digest = image.removeprefix("sha256:")
|
||||
@@ -701,14 +721,14 @@ def pinned_local_image_ref(ref: str) -> str:
|
||||
repository = repository[:last_colon]
|
||||
pinned_ref = f"{repository}:sha256-{digest}"
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "image", "tag", image, pinned_ref],
|
||||
[_CONTAINER, "image", "tag", ref, pinned_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"could not tag exact local image {image}: "
|
||||
f"could not tag exact local image {image} via {ref!r}: "
|
||||
f"{(result.stderr or result.stdout or '').strip() or '<no detail>'}"
|
||||
)
|
||||
if image_id(pinned_ref) != image:
|
||||
|
||||
@@ -9,7 +9,7 @@ backend-specific final resolution.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
|
||||
@@ -17,6 +17,7 @@ from ..egress import EgressPlan
|
||||
from ..env import ResolvedEnv, resolve_env
|
||||
from ..git_gate import GitGate, GitGatePlan
|
||||
from ..manifest import Manifest
|
||||
from ..release_manifest import oci_image
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..workspace import workspace_plan
|
||||
from .resolve_common import (
|
||||
@@ -112,6 +113,11 @@ class BottlePreparationPlanner:
|
||||
color=spec.color,
|
||||
provider_settings=provider_config.settings,
|
||||
)
|
||||
if not provider_config.dockerfile:
|
||||
image, local = oci_image(
|
||||
f"agent_{provider_config.template}", provision.image)
|
||||
if not local:
|
||||
provision = replace(provision, image=image)
|
||||
provision = merge_provision_env_vars(provision)
|
||||
return PreparedBottle(
|
||||
manifest=manifest,
|
||||
|
||||
@@ -63,12 +63,23 @@ def provision_git_gate(
|
||||
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
||||
creds = _creds_dir(bottle_id)
|
||||
transport.exec(["mkdir", "-p", creds])
|
||||
transport.exec(["chmod", "700", creds])
|
||||
credential_paths: list[str] = []
|
||||
for u in plan.upstreams:
|
||||
if u.identity_file:
|
||||
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
|
||||
key_path = f"{creds}/{u.name}-key"
|
||||
transport.cp_into(u.identity_file, key_path)
|
||||
credential_paths.append(key_path)
|
||||
known_hosts = str(u.known_hosts_file)
|
||||
if known_hosts and known_hosts != ".":
|
||||
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
|
||||
known_hosts_path = f"{creds}/{u.name}-known_hosts"
|
||||
transport.cp_into(known_hosts, known_hosts_path)
|
||||
credential_paths.append(known_hosts_path)
|
||||
# Copy-mode behavior differs across Docker, Apple Container, and SSH.
|
||||
# Apply the security contract inside the gateway so every backend produces
|
||||
# the same private credential namespace.
|
||||
if credential_paths:
|
||||
transport.exec(["chmod", "600", *credential_paths])
|
||||
# Init the bare repos + per-repo credential config for this namespace.
|
||||
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
||||
transport.exec(["sh", "-c", script])
|
||||
|
||||
@@ -86,7 +86,7 @@ def _print_vm_install_instructions() -> None:
|
||||
info("Then start the service: container system start")
|
||||
else:
|
||||
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
|
||||
info("Configure the host: ./cli.py backend setup")
|
||||
info("Configure the host: bot-bottle backend setup")
|
||||
|
||||
|
||||
def _auto_select_backend(prompt: bool = True) -> str:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""`backend` CLI command — generic host setup/status across backends.
|
||||
|
||||
`./cli.py backend setup [--backend=NAME]` provisions (or points at how
|
||||
`bot-bottle backend setup [--backend=NAME]` provisions (or points at how
|
||||
to provision) the chosen backend's one-time host prerequisites.
|
||||
`./cli.py backend status [--backend=NAME]` reports readiness.
|
||||
`./cli.py backend teardown [--backend=NAME]` undoes setup (uninstall).
|
||||
`bot-bottle backend status [--backend=NAME]` reports readiness.
|
||||
`bot-bottle backend teardown [--backend=NAME]` undoes setup (uninstall).
|
||||
|
||||
All dispatch to the backend's `setup()` / `status()` / `teardown()`
|
||||
classmethods, so there are no backend-specific commands — swapping
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""cleanup: stop and remove all orphaned bot-bottle resources.
|
||||
|
||||
Walks every registered backend (docker, firecracker, macos-container)
|
||||
so a single `./cli.py cleanup` reaps every backend's leftovers — a
|
||||
so a single `bot-bottle cleanup` reaps every backend's leftovers — a
|
||||
firecracker bottle's VM processes and run dirs won't survive a
|
||||
docker-only cleanup pass (issue addressed alongside #77).
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
|
||||
from ...backend import get_bottle_backend, has_backend, known_backend_names
|
||||
from ...backend.cleanup_control import CleanupError
|
||||
from ...log import info
|
||||
from ...util import read_tty_line
|
||||
|
||||
@@ -54,12 +55,18 @@ def cmd_cleanup(_argv: list[str]) -> int:
|
||||
|
||||
# Confirmation authorizes a fresh authoritative snapshot, not blind use of
|
||||
# identities that may have changed while the operator reviewed the preview.
|
||||
refreshed = [(name, backend, backend.prepare_cleanup())
|
||||
for name, backend, _plan in prepared]
|
||||
for name, backend, plan in refreshed:
|
||||
if plan.empty:
|
||||
failures: list[str] = []
|
||||
for name, backend, displayed in prepared:
|
||||
current = backend.prepare_cleanup()
|
||||
approved = displayed.intersect(current)
|
||||
if approved.empty:
|
||||
continue
|
||||
backend.cleanup(plan)
|
||||
try:
|
||||
backend.cleanup(approved)
|
||||
except CleanupError as exc:
|
||||
failures.append(f"{name}: {exc}")
|
||||
if failures:
|
||||
raise CleanupError("cleanup incomplete: " + "; ".join(failures))
|
||||
info("cleanup: done")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Docker bottles are committed to a local Docker image. Macos-container
|
||||
bottles are exported and rebuilt as a local Apple Container image.
|
||||
Firecracker bottles stream the guest rootfs out over SSH and rebuild a
|
||||
local Docker image. The resulting reference is stored in per-bottle
|
||||
state so the next `./cli.py resume <slug>` boots from the snapshot
|
||||
state so the next `bot-bottle resume <slug>` boots from the snapshot
|
||||
instead of rebuilding from the Dockerfile.
|
||||
"""
|
||||
|
||||
@@ -37,7 +37,7 @@ def cmd_commit(argv: list[str]) -> int:
|
||||
if slug is None:
|
||||
active = enumerate_active_agents()
|
||||
if not active:
|
||||
die("no active bottles; start one with `./cli.py start`")
|
||||
die("no active bottles; start one with `bot-bottle start`")
|
||||
choices = [a.slug for a in active]
|
||||
slug = tui.filter_select(choices, title="Select bottle to commit")
|
||||
if slug is None:
|
||||
|
||||
@@ -8,7 +8,7 @@ override and transcript snapshot under the same state dir.
|
||||
|
||||
Use case: an interrupted or preserved bottle needs to be relaunched;
|
||||
the operator runs
|
||||
./cli.py resume <identity>
|
||||
bot-bottle resume <identity>
|
||||
to bring up the replacement from the recorded state.
|
||||
"""
|
||||
|
||||
|
||||
@@ -203,19 +203,19 @@ def _start_headless(
|
||||
if not os.isatty(stdin_fd):
|
||||
die(
|
||||
"--headless requires a PTY on stdin; run via:\n"
|
||||
" script -q /dev/null ./cli.py start ..."
|
||||
" script -q /dev/null bot-bottle start ..."
|
||||
)
|
||||
|
||||
agent_name = args.name
|
||||
if not agent_name:
|
||||
die("--headless requires an agent name: ./cli.py start <agent> --headless")
|
||||
die("--headless requires an agent name: bot-bottle start <agent> --headless")
|
||||
manifest.require_agent(agent_name) # raises ManifestError if unknown
|
||||
|
||||
prompt = args.prompt
|
||||
if not prompt:
|
||||
die(
|
||||
"--headless requires --prompt: "
|
||||
"./cli.py start <agent> --headless --prompt 'Do the thing'"
|
||||
"bot-bottle start <agent> --headless --prompt 'Do the thing'"
|
||||
)
|
||||
|
||||
if args.bottle:
|
||||
@@ -319,9 +319,9 @@ def attach_agent(
|
||||
|
||||
`resume=True` adds `--continue` so claude picks up its most
|
||||
recent session non-interactively (no session-picker prompt).
|
||||
First-attach paths (`./cli.py start`) leave it False.
|
||||
First-attach paths (`bot-bottle start`) leave it False.
|
||||
|
||||
Used as the inner step of `./cli.py start`."""
|
||||
Used as the inner step of `bot-bottle start`."""
|
||||
runtime = runtime_for(agent_provider_template)
|
||||
info(
|
||||
f"attaching interactive {agent_provider_template} session "
|
||||
@@ -354,7 +354,7 @@ def settle_state(identity: str) -> None:
|
||||
if not identity:
|
||||
return
|
||||
if is_preserved(identity):
|
||||
info(f"to resume this bottle: ./cli.py resume {identity}")
|
||||
info(f"to resume this bottle: bot-bottle resume {identity}")
|
||||
return
|
||||
cleanup_state(identity)
|
||||
|
||||
|
||||
@@ -110,7 +110,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.label}]"
|
||||
return f"{base}; resume: ./cli.py resume {qp.label}"
|
||||
return f"{base}; resume: bot-bottle resume {qp.label}"
|
||||
|
||||
|
||||
def _detail_lines(
|
||||
|
||||
@@ -115,10 +115,12 @@ class Gateway(abc.ABC):
|
||||
|
||||
name: str
|
||||
|
||||
def ensure_available(self) -> None:
|
||||
"""Acquire the exact image/rootfs selected for this application."""
|
||||
self.ensure_built()
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Ensure the gateway's image / rootfs exists, building it if needed.
|
||||
Default: nothing to build (e.g. a stub or a pre-pulled image). Call
|
||||
before `connect_to_orchestrator`."""
|
||||
"""Local-build implementation hook retained for backend compatibility."""
|
||||
return
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import io
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
@@ -14,6 +15,10 @@ class Readable(Protocol):
|
||||
def read(self, size: int = -1, /) -> bytes: ...
|
||||
|
||||
|
||||
class Writable(Protocol):
|
||||
def write(self, data: bytes, /) -> object: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyReadError(Exception):
|
||||
status: int
|
||||
@@ -30,6 +35,25 @@ def read_declared_body(
|
||||
require_length: bool,
|
||||
) -> bytes:
|
||||
"""Validate and read exactly one declared body under a read deadline."""
|
||||
output = io.BytesIO()
|
||||
copy_declared_body(
|
||||
stream, output, connection, raw_length, maximum=maximum,
|
||||
timeout_seconds=timeout_seconds, require_length=require_length,
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def copy_declared_body(
|
||||
stream: Readable,
|
||||
output: Writable,
|
||||
connection: socket.socket,
|
||||
raw_length: str | None,
|
||||
*,
|
||||
maximum: int,
|
||||
timeout_seconds: float,
|
||||
require_length: bool,
|
||||
) -> int:
|
||||
"""Copy one declared body to a sink without retaining it in memory."""
|
||||
if raw_length is None:
|
||||
if require_length:
|
||||
raise BodyReadError(411, "Content-Length required")
|
||||
@@ -44,7 +68,6 @@ def read_declared_body(
|
||||
raise BodyReadError(413, "request body too large")
|
||||
previous_timeout = connection.gettimeout()
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
chunks: list[bytes] = []
|
||||
remaining = length
|
||||
try:
|
||||
while remaining:
|
||||
@@ -55,13 +78,13 @@ def read_declared_body(
|
||||
chunk = stream.read(min(remaining, 64 * 1024))
|
||||
if not chunk:
|
||||
raise BodyReadError(400, "incomplete request body")
|
||||
chunks.append(chunk)
|
||||
output.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
except TimeoutError as exc:
|
||||
raise BodyReadError(408, "request body read timed out") from exc
|
||||
finally:
|
||||
connection.settimeout(previous_timeout)
|
||||
return b"".join(chunks)
|
||||
return length
|
||||
|
||||
|
||||
class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
|
||||
@@ -106,4 +129,9 @@ class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
|
||||
self._request_slots.release()
|
||||
|
||||
|
||||
__all__ = ["BodyReadError", "BoundedThreadingHTTPServer", "read_declared_body"]
|
||||
__all__ = [
|
||||
"BodyReadError",
|
||||
"BoundedThreadingHTTPServer",
|
||||
"copy_declared_body",
|
||||
"read_declared_body",
|
||||
]
|
||||
|
||||
@@ -21,6 +21,8 @@ from __future__ import annotations
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import typing
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
@@ -30,7 +32,7 @@ from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||
from bot_bottle.gateway.bounded_http import (
|
||||
BodyReadError,
|
||||
BoundedThreadingHTTPServer,
|
||||
read_declared_body,
|
||||
copy_declared_body,
|
||||
)
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
|
||||
@@ -84,6 +86,8 @@ def resolve_sandbox_root(
|
||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||
REQUEST_BODY_TIMEOUT_SECONDS = 30.0
|
||||
MAX_REQUEST_WORKERS = 16
|
||||
MAX_BODY_WORKERS = 2
|
||||
_BODY_WORK_SLOTS = threading.BoundedSemaphore(MAX_BODY_WORKERS)
|
||||
|
||||
|
||||
class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
@@ -191,26 +195,40 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
value = self.headers.get(header)
|
||||
if value:
|
||||
env[variable] = value
|
||||
try:
|
||||
body = read_declared_body(
|
||||
self.rfile,
|
||||
self.connection,
|
||||
self.headers.get("content-length"),
|
||||
maximum=MAX_BODY_BYTES,
|
||||
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
|
||||
require_length=False,
|
||||
)
|
||||
except BodyReadError as exc:
|
||||
self.send_error(exc.status, exc.message)
|
||||
if not _BODY_WORK_SLOTS.acquire(blocking=False):
|
||||
self.send_error(503, "git request capacity exhausted")
|
||||
return
|
||||
proc = subprocess.run(
|
||||
["git", "http-backend"],
|
||||
input=body,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||
)
|
||||
try:
|
||||
with tempfile.TemporaryFile() as body:
|
||||
try:
|
||||
copy_declared_body(
|
||||
self.rfile,
|
||||
body,
|
||||
self.connection,
|
||||
self.headers.get("content-length"),
|
||||
maximum=MAX_BODY_BYTES,
|
||||
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
|
||||
require_length=False,
|
||||
)
|
||||
except BodyReadError as exc:
|
||||
self.send_error(exc.status, exc.message)
|
||||
return
|
||||
body.seek(0)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "http-backend"],
|
||||
stdin=body,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
self.log_message("git http-backend unavailable: %s", exc)
|
||||
self.send_error(503, "git backend unavailable")
|
||||
return
|
||||
finally:
|
||||
_BODY_WORK_SLOTS.release()
|
||||
self._write_cgi_response(proc.stdout)
|
||||
|
||||
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
|
||||
|
||||
@@ -385,7 +385,7 @@ PY
|
||||
;;
|
||||
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
|
||||
echo "git-gate: approve with 'bot-bottle supervise' to continue this push" >&2
|
||||
waited=0
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY'
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
import math
|
||||
import sys
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
@@ -208,6 +209,19 @@ def create_app(orch: OrchestratorCore, *, signing_key: str) -> FastAPI:
|
||||
)
|
||||
app.add_middleware(ControlPlaneBoundary, signing_key=key)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def invalid_request(
|
||||
_request: object, exc: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
errors = exc.errors()
|
||||
location = errors[0].get("loc", ()) if errors else ()
|
||||
field = str(location[1]) if len(location) > 1 else ""
|
||||
suffix = f": {field}" if field else ""
|
||||
return JSONResponse(
|
||||
{"error": f"invalid request body{suffix}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -63,10 +63,12 @@ class Orchestrator(abc.ABC):
|
||||
# orchestrator never starts without its signing key.
|
||||
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
|
||||
|
||||
def ensure_available(self) -> None:
|
||||
"""Acquire the exact image/rootfs selected for this application."""
|
||||
self.ensure_built()
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Ensure the orchestrator's image / rootfs exists, building it if
|
||||
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
||||
`ensure_running`."""
|
||||
"""Local-build implementation hook retained for backend compatibility."""
|
||||
return
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -366,7 +366,7 @@ class OrchestratorCore:
|
||||
value with *env_var_secret*, and restores ``_tokens[bottle_id]``.
|
||||
Returns True on success, False when no stored secrets exist for this
|
||||
bottle or decryption fails (wrong key / corrupt data)."""
|
||||
from .store.secret_store import decrypt_value, encrypt_value, is_legacy_blob
|
||||
from .store.secret_store import decrypt_value
|
||||
encrypted = self.registry.get_agent_secrets(bottle_id)
|
||||
if not encrypted:
|
||||
return False
|
||||
@@ -377,12 +377,6 @@ class OrchestratorCore:
|
||||
except ValueError:
|
||||
return False
|
||||
self._tokens[bottle_id] = decrypted
|
||||
if any(is_legacy_blob(value) for value in encrypted.values()):
|
||||
migrated = {
|
||||
key: encrypt_value(env_var_secret, value)
|
||||
for key, value in decrypted.items()
|
||||
}
|
||||
self.registry.store_agent_secrets(bottle_id, migrated)
|
||||
return True
|
||||
|
||||
# --- consolidated gateway ----------------------------------------------
|
||||
|
||||
@@ -129,6 +129,10 @@ _MIGRATIONS = TableMigrations(
|
||||
# v5 — index for fast per-bottle lookups and bulk DELETE on teardown.
|
||||
"CREATE INDEX IF NOT EXISTS idx_bottled_agent_secrets_id "
|
||||
"ON bottled_agent_secrets (bottled_agent_id, type)",
|
||||
# v6 — unauthenticated legacy ciphertext must never be selected by
|
||||
# attacker-controlled blob contents. Existing local agents are
|
||||
# intentionally reprovisioned instead of retaining downgrade support.
|
||||
"DELETE FROM bottled_agent_secrets",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ value is encrypted independently. New output blobs are:
|
||||
|
||||
``version || nonce (16 bytes) || ciphertext || tag (32 bytes)``
|
||||
|
||||
encoded as URL-safe base64 (no padding). The version marker lets the reader
|
||||
accept legacy ``nonce || ciphertext`` rows long enough to rewrite them in the
|
||||
authenticated format after a successful reprovision.
|
||||
encoded as URL-safe base64 (no padding). Unversioned legacy ciphertext is
|
||||
rejected; the registry migration clears those rows rather than allowing blob
|
||||
contents to select an unauthenticated decoder.
|
||||
|
||||
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
|
||||
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
|
||||
@@ -84,44 +84,18 @@ def encrypt_value(secret_b64: str, plaintext: str) -> str:
|
||||
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def is_legacy_blob(blob_b64: str) -> bool:
|
||||
"""Whether *blob_b64* uses the pre-authentication storage format."""
|
||||
try:
|
||||
return not _b64dec(blob_b64).startswith(_VERSION)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _decrypt_legacy(key: bytes, blob: bytes) -> str:
|
||||
"""Read the original ``nonce || ciphertext`` format for migration only."""
|
||||
if len(blob) < _NONCE_BYTES:
|
||||
raise ValueError("ciphertext blob too short")
|
||||
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
|
||||
pt = bytearray()
|
||||
# The legacy format used the byte offset as the PRF counter.
|
||||
for i in range(0, len(ciphertext), _BLOCK):
|
||||
chunk = ciphertext[i : i + _BLOCK]
|
||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
||||
try:
|
||||
return bytes(pt).decode()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"decryption produced non-UTF-8 output: {exc}") from exc
|
||||
|
||||
|
||||
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
||||
"""Decrypt a blob produced by :func:`encrypt_value`.
|
||||
|
||||
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
||||
input, authentication failure, or a key mismatch. Legacy unauthenticated
|
||||
rows remain readable so callers can migrate them immediately."""
|
||||
input, authentication failure, or a key mismatch."""
|
||||
key = _b64dec(secret_b64)
|
||||
try:
|
||||
blob = _b64dec(blob_b64)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
||||
if not blob.startswith(_VERSION):
|
||||
return _decrypt_legacy(key, blob)
|
||||
raise ValueError("unsupported ciphertext format")
|
||||
minimum = len(_VERSION) + _NONCE_BYTES + _TAG_BYTES
|
||||
if len(blob) < minimum:
|
||||
raise ValueError("ciphertext blob too short")
|
||||
@@ -152,5 +126,4 @@ __all__ = [
|
||||
"new_env_var_secret",
|
||||
"encrypt_value",
|
||||
"decrypt_value",
|
||||
"is_legacy_blob",
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schema": 1, "development": true}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""External, commit-addressed release bundle metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .release_manifest import ReleaseManifestError, parse_manifest
|
||||
|
||||
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_bundle_index(
|
||||
manifest_data: Any,
|
||||
*,
|
||||
wheel: Path,
|
||||
wheel_url: str,
|
||||
workflow_run: str,
|
||||
published_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build and validate the immutable external index for one source commit."""
|
||||
manifest = parse_manifest(manifest_data)
|
||||
if not wheel.is_file() or wheel.suffix != ".whl":
|
||||
raise ReleaseManifestError("release bundle wheel must be one .whl file")
|
||||
if not wheel_url.startswith("https://") or not wheel_url.endswith(wheel.name):
|
||||
raise ReleaseManifestError(
|
||||
"release bundle wheel URL must be HTTPS and end with the wheel filename")
|
||||
if not workflow_run.strip() or not published_at.strip():
|
||||
raise ReleaseManifestError(
|
||||
"release bundle provenance requires workflow_run and published_at")
|
||||
return {
|
||||
"schema": 1,
|
||||
"source_commit": manifest.source_commit,
|
||||
"wheel": {
|
||||
"filename": wheel.name,
|
||||
"url": wheel_url,
|
||||
"sha256": sha256_file(wheel),
|
||||
},
|
||||
"oci": {
|
||||
"orchestrator": manifest.orchestrator_image,
|
||||
"gateway": manifest.gateway_image,
|
||||
**{
|
||||
f"agent_{role}": reference
|
||||
for role, reference in manifest.agent_images.items()
|
||||
},
|
||||
},
|
||||
"firecracker": {
|
||||
"orchestrator": {
|
||||
"version": manifest.firecracker_orchestrator.version,
|
||||
"sha256": manifest.firecracker_orchestrator.sha256,
|
||||
},
|
||||
"gateway": {
|
||||
"version": manifest.firecracker_gateway.version,
|
||||
"sha256": manifest.firecracker_gateway.sha256,
|
||||
},
|
||||
},
|
||||
"provenance": {
|
||||
"workflow_run": workflow_run.strip(),
|
||||
"published_at": published_at.strip(),
|
||||
},
|
||||
"qualifications": [],
|
||||
}
|
||||
|
||||
|
||||
def parse_bundle_index(data: Any) -> dict[str, Any]:
|
||||
"""Validate an index before publication or installer consumption."""
|
||||
if not isinstance(data, dict) or data.get("schema") != 1:
|
||||
raise ReleaseManifestError("release bundle index must use schema 1")
|
||||
commit = data.get("source_commit")
|
||||
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
|
||||
raise ReleaseManifestError(
|
||||
"release bundle source_commit must be 40 lowercase hex")
|
||||
wheel = data.get("wheel")
|
||||
if not isinstance(wheel, dict):
|
||||
raise ReleaseManifestError("release bundle wheel must be an object")
|
||||
filename, url, digest = (
|
||||
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
|
||||
if not isinstance(filename, str) or not filename.endswith(".whl"):
|
||||
raise ReleaseManifestError("release bundle wheel filename must end in .whl")
|
||||
if not isinstance(url, str) or not url.startswith("https://") or not url.endswith(filename):
|
||||
raise ReleaseManifestError(
|
||||
"release bundle wheel URL must be HTTPS and match its filename")
|
||||
if not isinstance(digest, str) or _SHA_RE.fullmatch(digest) is None:
|
||||
raise ReleaseManifestError(
|
||||
"release bundle wheel sha256 must be 64 lowercase hex")
|
||||
manifest_data = {
|
||||
"schema": 1,
|
||||
"source_commit": commit,
|
||||
"oci": data.get("oci"),
|
||||
"firecracker": data.get("firecracker"),
|
||||
}
|
||||
parse_manifest(manifest_data)
|
||||
provenance = data.get("provenance")
|
||||
if not isinstance(provenance, dict) or not all(
|
||||
isinstance(provenance.get(key), str) and provenance[key].strip()
|
||||
for key in ("workflow_run", "published_at")
|
||||
):
|
||||
raise ReleaseManifestError("release bundle provenance is incomplete")
|
||||
qualifications = data.get("qualifications")
|
||||
if not isinstance(qualifications, list):
|
||||
raise ReleaseManifestError("release bundle qualifications must be an array")
|
||||
return data
|
||||
|
||||
|
||||
def canonical_bytes(data: Any) -> bytes:
|
||||
parse_bundle_index(data)
|
||||
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_bundle_index",
|
||||
"canonical_bytes",
|
||||
"parse_bundle_index",
|
||||
"sha256_file",
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Immutable infrastructure identities assigned when bot-bottle is packaged."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import resources
|
||||
|
||||
_MANIFEST_NAME = "release-manifest.json"
|
||||
_OCI_RE = re.compile(r"^[^@\s]+@sha256:([0-9a-f]{64})$")
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ReleaseManifestError(RuntimeError):
|
||||
"""The installed package has no usable infrastructure release identity."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirecrackerArtifact:
|
||||
version: str
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseManifest:
|
||||
source_commit: str
|
||||
orchestrator_image: str
|
||||
gateway_image: str
|
||||
agent_images: dict[str, str]
|
||||
firecracker_orchestrator: FirecrackerArtifact
|
||||
firecracker_gateway: FirecrackerArtifact
|
||||
|
||||
|
||||
def manifest_path() -> Path:
|
||||
override = os.environ.get("BOT_BOTTLE_RELEASE_MANIFEST", "").strip()
|
||||
return Path(override) if override else Path(__file__).resolve().parent / _MANIFEST_NAME
|
||||
|
||||
|
||||
def _artifact(value: Any, role: str) -> FirecrackerArtifact:
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseManifestError(f"release manifest firecracker.{role} must be an object")
|
||||
version = value.get("version")
|
||||
sha256 = value.get("sha256")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise ReleaseManifestError(
|
||||
f"release manifest firecracker.{role}.version must be non-empty")
|
||||
if not isinstance(sha256, str) or _SHA_RE.fullmatch(sha256) is None:
|
||||
raise ReleaseManifestError(
|
||||
f"release manifest firecracker.{role}.sha256 must be 64 lowercase hex")
|
||||
return FirecrackerArtifact(version.strip(), sha256)
|
||||
|
||||
|
||||
def parse_manifest(data: Any) -> ReleaseManifest:
|
||||
if not isinstance(data, dict) or data.get("schema") != 1:
|
||||
raise ReleaseManifestError("release manifest must use schema 1")
|
||||
commit = data.get("source_commit")
|
||||
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
|
||||
raise ReleaseManifestError(
|
||||
"release manifest source_commit must be 40 lowercase hex")
|
||||
oci = data.get("oci")
|
||||
if not isinstance(oci, dict):
|
||||
raise ReleaseManifestError("release manifest oci must be an object")
|
||||
images: dict[str, str] = {}
|
||||
for role in ("orchestrator", "gateway", "agent_claude", "agent_codex", "agent_pi"):
|
||||
ref = oci.get(role)
|
||||
if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None:
|
||||
raise ReleaseManifestError(
|
||||
f"release manifest oci.{role} must be digest-pinned")
|
||||
images[role] = ref
|
||||
firecracker = data.get("firecracker")
|
||||
if not isinstance(firecracker, dict):
|
||||
raise ReleaseManifestError("release manifest firecracker must be an object")
|
||||
return ReleaseManifest(
|
||||
source_commit=commit,
|
||||
orchestrator_image=images["orchestrator"],
|
||||
gateway_image=images["gateway"],
|
||||
agent_images={
|
||||
role: images[f"agent_{role}"] for role in ("claude", "codex", "pi")
|
||||
},
|
||||
firecracker_orchestrator=_artifact(
|
||||
firecracker.get("orchestrator"), "orchestrator"),
|
||||
firecracker_gateway=_artifact(firecracker.get("gateway"), "gateway"),
|
||||
)
|
||||
|
||||
|
||||
def load_manifest() -> ReleaseManifest:
|
||||
path = manifest_path()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseManifestError(
|
||||
f"bot-bottle was packaged without valid infrastructure pins "
|
||||
f"({path}): {exc}") from exc
|
||||
return parse_manifest(data)
|
||||
|
||||
|
||||
def packaged_manifest() -> ReleaseManifest | None:
|
||||
"""Release identity, or ``None`` for checkouts/ad-hoc development wheels."""
|
||||
if resources.is_source_checkout() and not os.environ.get(
|
||||
"BOT_BOTTLE_RELEASE_MANIFEST"
|
||||
):
|
||||
return None
|
||||
path = manifest_path()
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return load_manifest() # raise the detailed, fail-closed error
|
||||
if isinstance(raw, dict) and raw.get("development") is True:
|
||||
return None
|
||||
return load_manifest()
|
||||
|
||||
|
||||
def local_build_requested() -> bool:
|
||||
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
|
||||
|
||||
|
||||
def oci_image(role: str, local_default: str) -> tuple[str, bool]:
|
||||
"""Return ``(reference, should_build_locally)`` for a fixed OCI service."""
|
||||
override_name = f"BOT_BOTTLE_{role.upper()}_IMAGE"
|
||||
override = os.environ.get(override_name, "").strip()
|
||||
manifest = packaged_manifest()
|
||||
local = local_build_requested() or manifest is None
|
||||
if override:
|
||||
if not local and _OCI_RE.fullmatch(override) is None:
|
||||
raise ReleaseManifestError(
|
||||
f"{override_name} must be digest-pinned outside local build mode")
|
||||
return override, local
|
||||
if local:
|
||||
return local_default, True
|
||||
assert manifest is not None
|
||||
return (
|
||||
manifest.orchestrator_image if role == "orchestrator"
|
||||
else manifest.gateway_image if role == "gateway"
|
||||
else manifest.agent_images[role.removeprefix("agent_")],
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FirecrackerArtifact",
|
||||
"ReleaseManifest",
|
||||
"ReleaseManifestError",
|
||||
"load_manifest",
|
||||
"local_build_requested",
|
||||
"oci_image",
|
||||
"packaged_manifest",
|
||||
"parse_manifest",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Durable publication of immutable, commit-addressed release bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from .release_bundle import canonical_bytes, parse_bundle_index, sha256_file
|
||||
from .release_manifest import ReleaseManifestError
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = 60.0
|
||||
INDEX_NAME = "bundle-index.json"
|
||||
PACKAGE_NAME = "bot-bottle-builds"
|
||||
|
||||
|
||||
def config() -> tuple[str, str, str]:
|
||||
"""Return generic-package base URL, owner, and write token."""
|
||||
base = os.environ.get(
|
||||
"BOT_BOTTLE_RELEASE_BASE", "https://gitea.dideric.is").rstrip("/")
|
||||
owner = os.environ.get("BOT_BOTTLE_RELEASE_OWNER", "didericis").strip()
|
||||
token = os.environ.get("BOT_BOTTLE_RELEASE_TOKEN", "")
|
||||
return base, owner, token
|
||||
|
||||
|
||||
def bundle_url(source_commit: str, filename: str) -> str:
|
||||
base, owner, _ = config()
|
||||
return (
|
||||
f"{base}/api/packages/{owner}/generic/{PACKAGE_NAME}/"
|
||||
f"{source_commit}/{filename}"
|
||||
)
|
||||
|
||||
|
||||
def request(url: str, *, method: str = "GET", data: object | None = None,
|
||||
length: int | None = None) -> urllib.request.Request:
|
||||
result = urllib.request.Request(url, data=data, method=method) # type: ignore[arg-type]
|
||||
_, _, token = config()
|
||||
if token:
|
||||
result.add_header("Authorization", f"token {token}")
|
||||
if length is not None:
|
||||
result.add_header("Content-Length", str(length))
|
||||
result.add_header("Content-Type", "application/octet-stream")
|
||||
return result
|
||||
|
||||
|
||||
def remote_bytes(url: str) -> bytes | None:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request(url), timeout=HTTP_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ReleaseManifestError(
|
||||
f"checking release bundle failed (HTTP {exc.code}): {url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ReleaseManifestError(
|
||||
f"release registry unreachable: {url} ({exc.reason})") from exc
|
||||
|
||||
|
||||
def remote_sha256(url: str) -> str | None:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request(url), timeout=HTTP_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
digest = hashlib.sha256()
|
||||
for chunk in iter(lambda: response.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ReleaseManifestError(
|
||||
f"checking release artifact failed (HTTP {exc.code}): {url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ReleaseManifestError(
|
||||
f"release registry unreachable: {url} ({exc.reason})") from exc
|
||||
|
||||
|
||||
def upload(url: str, source: bytes | Path) -> None:
|
||||
handle = None
|
||||
if isinstance(source, Path):
|
||||
handle = source.open("rb")
|
||||
data: object = handle
|
||||
length = source.stat().st_size
|
||||
else:
|
||||
data = source
|
||||
length = len(source)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request(url, method="PUT", data=data, length=length),
|
||||
timeout=HTTP_TIMEOUT_SECONDS,
|
||||
):
|
||||
pass
|
||||
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
|
||||
raise ReleaseManifestError(
|
||||
f"publishing release artifact failed: {url} ({exc})") from exc
|
||||
finally:
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
|
||||
|
||||
def publish_bundle(index: dict[str, object], wheel: Path) -> str:
|
||||
"""Publish wheel first and index last, or verify an existing exact bundle."""
|
||||
parse_bundle_index(index)
|
||||
source_commit = str(index["source_commit"])
|
||||
wheel_data = index["wheel"]
|
||||
assert isinstance(wheel_data, dict)
|
||||
filename = str(wheel_data["filename"])
|
||||
expected_sha = str(wheel_data["sha256"])
|
||||
if wheel.name != filename or sha256_file(wheel) != expected_sha:
|
||||
raise ReleaseManifestError(
|
||||
"release bundle wheel does not match its index")
|
||||
|
||||
wheel_url = bundle_url(source_commit, filename)
|
||||
index_url = bundle_url(source_commit, INDEX_NAME)
|
||||
if wheel_data["url"] != wheel_url:
|
||||
raise ReleaseManifestError(
|
||||
"release bundle wheel URL does not match its commit coordinate")
|
||||
|
||||
wanted_index = canonical_bytes(index)
|
||||
existing_index = remote_bytes(index_url)
|
||||
existing_wheel_sha = remote_sha256(wheel_url)
|
||||
if existing_index is not None:
|
||||
if existing_index != wanted_index or existing_wheel_sha != expected_sha:
|
||||
raise ReleaseManifestError(
|
||||
f"immutable release bundle {source_commit} already differs")
|
||||
return index_url
|
||||
if existing_wheel_sha is not None:
|
||||
raise ReleaseManifestError(
|
||||
f"partial release bundle {source_commit} exists without its index; "
|
||||
"administrative cleanup is required")
|
||||
|
||||
upload(wheel_url, wheel)
|
||||
upload(index_url, wanted_index)
|
||||
return index_url
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INDEX_NAME",
|
||||
"PACKAGE_NAME",
|
||||
"bundle_url",
|
||||
"config",
|
||||
"publish_bundle",
|
||||
]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Validated release and channel pointers for qualified commit bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .release_bundle import parse_bundle_index
|
||||
from .release_manifest import ReleaseManifestError
|
||||
from .release_publish import (
|
||||
HTTP_TIMEOUT_SECONDS,
|
||||
bundle_url,
|
||||
config,
|
||||
remote_bytes,
|
||||
request,
|
||||
upload,
|
||||
)
|
||||
|
||||
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_STABLE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||
_STAGING_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$")
|
||||
|
||||
|
||||
def release_channel(tag: str) -> str:
|
||||
"""Return the only channel eligible for *tag*."""
|
||||
if _STABLE_TAG_RE.fullmatch(tag):
|
||||
return "production"
|
||||
if _STAGING_TAG_RE.fullmatch(tag):
|
||||
return "staging"
|
||||
raise ReleaseManifestError(
|
||||
"release tag must be vX.Y.Z or vX.Y.Z-rc.N")
|
||||
|
||||
|
||||
def release_version(tag: str) -> tuple[int, int, int, int]:
|
||||
"""Return a monotonically comparable version tuple."""
|
||||
stable = _STABLE_TAG_RE.fullmatch(tag)
|
||||
if stable:
|
||||
return (
|
||||
int(stable.group(1)),
|
||||
int(stable.group(2)),
|
||||
int(stable.group(3)),
|
||||
1 << 30,
|
||||
)
|
||||
staging = _STAGING_TAG_RE.fullmatch(tag)
|
||||
if staging:
|
||||
return (
|
||||
int(staging.group(1)),
|
||||
int(staging.group(2)),
|
||||
int(staging.group(3)),
|
||||
int(staging.group(4)),
|
||||
)
|
||||
raise ReleaseManifestError(
|
||||
"release tag must be vX.Y.Z or vX.Y.Z-rc.N")
|
||||
|
||||
|
||||
def pointer_url(package: str, version: str, filename: str) -> str:
|
||||
base, owner, _ = config()
|
||||
return f"{base}/api/packages/{owner}/generic/{package}/{version}/{filename}"
|
||||
|
||||
|
||||
def build_release_pointer(
|
||||
*,
|
||||
tag: str,
|
||||
source_commit: str,
|
||||
workflow_run: str,
|
||||
qualified_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a qualified pointer to one immutable commit bundle."""
|
||||
channel = release_channel(tag)
|
||||
if _COMMIT_RE.fullmatch(source_commit) is None:
|
||||
raise ReleaseManifestError(
|
||||
"release source_commit must be 40 lowercase hex")
|
||||
if not workflow_run.strip() or not qualified_at.strip():
|
||||
raise ReleaseManifestError(
|
||||
"release qualification provenance is incomplete")
|
||||
return {
|
||||
"schema": 1,
|
||||
"qualified": True,
|
||||
"tag": tag,
|
||||
"channel": channel,
|
||||
"source_commit": source_commit,
|
||||
"bundle_index_url": bundle_url(source_commit, "bundle-index.json"),
|
||||
"qualification": {
|
||||
"workflow_run": workflow_run.strip(),
|
||||
"qualified_at": qualified_at.strip(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_release_pointer(data: Any) -> dict[str, Any]:
|
||||
"""Validate a release or channel pointer."""
|
||||
if not isinstance(data, dict) or data.get("schema") != 1:
|
||||
raise ReleaseManifestError("release pointer must use schema 1")
|
||||
if data.get("qualified") is not True:
|
||||
raise ReleaseManifestError("release pointer must be qualified")
|
||||
tag = data.get("tag")
|
||||
channel = data.get("channel")
|
||||
commit = data.get("source_commit")
|
||||
if not isinstance(tag, str) or release_channel(tag) != channel:
|
||||
raise ReleaseManifestError("release pointer tag and channel disagree")
|
||||
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
|
||||
raise ReleaseManifestError("release pointer source commit is invalid")
|
||||
if data.get("bundle_index_url") != bundle_url(commit, "bundle-index.json"):
|
||||
raise ReleaseManifestError("release pointer bundle URL is invalid")
|
||||
qualification = data.get("qualification")
|
||||
if not isinstance(qualification, dict) or not all(
|
||||
isinstance(qualification.get(key), str) and qualification[key].strip()
|
||||
for key in ("workflow_run", "qualified_at")
|
||||
):
|
||||
raise ReleaseManifestError("release qualification is incomplete")
|
||||
return data
|
||||
|
||||
|
||||
def canonical_pointer(data: Any) -> bytes:
|
||||
parse_release_pointer(data)
|
||||
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def delete(url: str) -> None:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request(url, method="DELETE"), timeout=HTTP_TIMEOUT_SECONDS,
|
||||
):
|
||||
pass
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 404:
|
||||
raise ReleaseManifestError(
|
||||
f"replacing release channel failed (HTTP {exc.code}): {url}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ReleaseManifestError(
|
||||
f"release registry unreachable: {url} ({exc.reason})") from exc
|
||||
|
||||
|
||||
def publish_qualification(
|
||||
pointer: dict[str, Any], *, allow_rollback: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
"""Publish an immutable tag pointer and advance its channel."""
|
||||
parse_release_pointer(pointer)
|
||||
tag = str(pointer["tag"])
|
||||
channel = str(pointer["channel"])
|
||||
wanted = canonical_pointer(pointer)
|
||||
release_url = pointer_url(
|
||||
"bot-bottle-releases", tag, "release.json")
|
||||
channel_url = pointer_url(
|
||||
"bot-bottle-channels", channel, "channel.json")
|
||||
|
||||
bundle = remote_bytes(str(pointer["bundle_index_url"]))
|
||||
if bundle is None:
|
||||
raise ReleaseManifestError(
|
||||
f"commit bundle does not exist for {pointer['source_commit']}")
|
||||
try:
|
||||
bundle_data = parse_bundle_index(json.loads(bundle))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ReleaseManifestError("commit bundle index is malformed") from exc
|
||||
if bundle_data["source_commit"] != pointer["source_commit"]:
|
||||
raise ReleaseManifestError(
|
||||
"commit bundle index does not match the qualified source commit")
|
||||
existing_release = remote_bytes(release_url)
|
||||
if existing_release is not None and existing_release != wanted:
|
||||
raise ReleaseManifestError(
|
||||
f"immutable release pointer {tag} already differs")
|
||||
if existing_release is None:
|
||||
upload(release_url, wanted)
|
||||
|
||||
existing_channel = remote_bytes(channel_url)
|
||||
if existing_channel == wanted:
|
||||
return release_url, channel_url
|
||||
if existing_channel is not None:
|
||||
try:
|
||||
current = parse_release_pointer(json.loads(existing_channel))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ReleaseManifestError(
|
||||
f"existing {channel} channel pointer is malformed") from exc
|
||||
if (
|
||||
not allow_rollback
|
||||
and release_version(tag) <= release_version(str(current["tag"]))
|
||||
):
|
||||
raise ReleaseManifestError(
|
||||
f"{channel} channel update is not monotonic; "
|
||||
"use the explicit rollback operation")
|
||||
delete(channel_url)
|
||||
upload(channel_url, wanted)
|
||||
return release_url, channel_url
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_release_pointer",
|
||||
"canonical_pointer",
|
||||
"parse_release_pointer",
|
||||
"publish_qualification",
|
||||
"release_channel",
|
||||
"release_version",
|
||||
]
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,9 +21,62 @@ class DbStore:
|
||||
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
|
||||
self.db_path = db_path
|
||||
self._migrations = migrations
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._secure_parent()
|
||||
if self.db_path.exists():
|
||||
self._chmod()
|
||||
|
||||
def _secure_parent(self) -> None:
|
||||
"""Create and verify the private parent directory."""
|
||||
parent = self.db_path.parent
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if parent.is_symlink():
|
||||
raise PermissionError(f"database directory must not be a symlink: {parent}")
|
||||
parent.chmod(0o700)
|
||||
parent_stat = parent.lstat()
|
||||
if not stat.S_ISDIR(parent_stat.st_mode):
|
||||
raise PermissionError(f"database parent is not a directory: {parent}")
|
||||
if stat.S_IMODE(parent_stat.st_mode) != 0o700:
|
||||
raise PermissionError(f"database directory is not mode 0700: {parent}")
|
||||
|
||||
def _secure_db_file(self) -> None:
|
||||
"""Create the database without a permissive filesystem window.
|
||||
|
||||
SQLite otherwise creates a missing database using the process umask.
|
||||
This store contains control-plane identity tokens, so both creation and
|
||||
repair are fail-closed rather than best-effort.
|
||||
"""
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
|
||||
fd = os.open(
|
||||
self.db_path,
|
||||
flags,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
try:
|
||||
self._secure_open_file(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _chmod(self) -> None:
|
||||
"""Enforce and verify the private database mode after every write."""
|
||||
fd = os.open(self.db_path, os.O_RDWR | os.O_NOFOLLOW)
|
||||
try:
|
||||
self._secure_open_file(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _secure_open_file(self, fd: int) -> None:
|
||||
"""Pin, validate, and secure an opened database filesystem object."""
|
||||
file_stat = os.fstat(fd)
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
raise PermissionError(
|
||||
f"database must be a regular file: {self.db_path}"
|
||||
)
|
||||
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
|
||||
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600:
|
||||
raise PermissionError(f"database is not mode 0600: {self.db_path}")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self._secure_db_file()
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
@@ -51,16 +106,9 @@ class DbStore:
|
||||
return version == len(self._migrations.migrations)
|
||||
|
||||
def migrate(self) -> None:
|
||||
"""Apply any pending migrations and set permissions on the DB file."""
|
||||
"""Apply any pending migrations to the already-secured DB file."""
|
||||
with self._connection() as conn:
|
||||
self._migrations.apply(conn)
|
||||
self._chmod()
|
||||
|
||||
def _chmod(self) -> None:
|
||||
try:
|
||||
self.db_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["DbStore", "DbVersionError"]
|
||||
|
||||
+17
@@ -45,6 +45,23 @@ report; macOS reports advisory coverage in its own job. The macOS infra
|
||||
container is a singleton, so its job uses a concurrency group and always tears
|
||||
the service down.
|
||||
|
||||
## Artifact publication and promotion
|
||||
|
||||
[`publish-artifacts.yml`](../.gitea/workflows/publish-artifacts.yml) is the
|
||||
manual deployment-test boundary. Dispatch it with any branch, tag, or commit;
|
||||
it resolves that input once, publishes the orchestrator, gateway, Claude,
|
||||
Codex, Pi, and Firecracker artifacts, then publishes only the wheel verified
|
||||
against those identities. Install the reported commit with
|
||||
`BOT_BOTTLE_REF=<sha> sh install.sh`; the installer warns that snapshots may
|
||||
not have completed release qualification.
|
||||
|
||||
[`release.yml`](../.gitea/workflows/release.yml) handles promotion tags.
|
||||
`vX.Y.Z-rc.N` must be reachable from `staging`; `vX.Y.Z` must be reachable
|
||||
from `production`. It runs the complete pre-release workflow, ensures that the
|
||||
same commit bundle is published, verifies a clean install, creates the
|
||||
qualified release pointer, and advances the matching channel. Promotion
|
||||
reuses immutable bundle bytes and never rebuilds them.
|
||||
|
||||
## Scheduled canary
|
||||
|
||||
[`.gitea/workflows/canaries.yml`](../.gitea/workflows/canaries.yml) runs weekly
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# VHS tape — drives `./cli.py start demo` interactively and asks
|
||||
# VHS tape — drives `bot-bottle start demo` interactively and asks
|
||||
# claude (the AI) to run four probes via natural-language prompts.
|
||||
# Setup (manifest + dummy SSH key + image pre-warm) and teardown
|
||||
# happen outside the tape; record via `bash scripts/demo-record.sh`,
|
||||
@@ -29,7 +29,7 @@ Show
|
||||
# defaults), one git upstream (unreachable on purpose so gitleaks runs
|
||||
# before the gate would forward), and a FAKE_TOKEN env var shaped like
|
||||
# a GitHub PAT.
|
||||
Type "./cli.py start demo"
|
||||
Type "bot-bottle start demo"
|
||||
Enter
|
||||
Sleep 8s
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
|
||||
can revisit, but the v1 of this PRD is one file = one bottle.
|
||||
|
||||
- **Hot-reload.** Changes to manifest files take effect at next
|
||||
`./cli.py start`; we do not watch the directory.
|
||||
`bot-bottle start`; we do not watch the directory.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -290,7 +290,7 @@ After this PRD:
|
||||
|
||||
### Cleanup CLI
|
||||
|
||||
`./cli.py cleanup` switches from "list every container with prefix
|
||||
`bot-bottle cleanup` switches from "list every container with prefix
|
||||
`bot-bottle-` and every network with prefix `bot-bottle-net-`
|
||||
or `bot-bottle-egress-`" to:
|
||||
|
||||
@@ -369,7 +369,7 @@ Sized for one PR each, in order.
|
||||
`docker compose up -d` + attach + teardown. Per-sidecar `start()`/
|
||||
`stop()` lifecycle methods deleted in the same chunk. Compose-
|
||||
log dump on teardown added.
|
||||
4. **Cleanup CLI on compose.** Switch `./cli.py cleanup` to
|
||||
4. **Cleanup CLI on compose.** Switch `bot-bottle cleanup` to
|
||||
`docker compose ls`-based discovery; keep prefix-scan as
|
||||
fallback for one release.
|
||||
5. **Dashboard.** Decide on the discovery question (open question
|
||||
|
||||
@@ -37,7 +37,7 @@ Two rough edges in the current dashboard:
|
||||
shows only pending proposals. If no agent has called a tool,
|
||||
the screen reads "no pending proposals" — even when five
|
||||
bottles are quietly working. The operator has to `docker
|
||||
compose ls` (or `./cli.py cleanup -n` to see the y/N preview)
|
||||
compose ls` (or `bot-bottle cleanup -n` to see the y/N preview)
|
||||
to find out what's actually live.
|
||||
|
||||
2. **`e` / `p` re-discover-and-disambiguate every invocation.**
|
||||
@@ -82,12 +82,12 @@ the "operator wants to make an unprompted change" case.
|
||||
global across bottles. Filtering ("show me only this agent's
|
||||
proposals") might be a follow-up but isn't this PRD.
|
||||
- **Agent lifecycle from the dashboard.** Starting / stopping
|
||||
agents stays in `./cli.py start` / `./cli.py cleanup`. The
|
||||
agents stays in `bot-bottle start` / `bot-bottle cleanup`. The
|
||||
dashboard reads state; it doesn't change it.
|
||||
- **Preserved-but-not-running bottles.** The active-agents list
|
||||
is strictly "what's running now" (cross-referenced from
|
||||
`docker compose ls`). Preserved state dirs without a live
|
||||
project don't appear — `./cli.py resume <identity>` is the
|
||||
project don't appear — `bot-bottle resume <identity>` is the
|
||||
path for those.
|
||||
- **A separate per-agent detail view.** The agent rows are
|
||||
one-line summaries. Pressing Enter on a proposal still drops
|
||||
@@ -125,7 +125,7 @@ the "operator wants to make an unprompted change" case.
|
||||
- Changes to proposal handling (`a` / `m` / `r` / Enter all
|
||||
unchanged).
|
||||
- Changes to the queue-dir / supervise sidecar protocol.
|
||||
- New CLI surface beyond what's in `./cli.py dashboard`.
|
||||
- New CLI surface beyond what's in `bot-bottle dashboard`.
|
||||
- Touching the manifest, compose renderer, launch lifecycle.
|
||||
|
||||
## Proposed design
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
Today the dashboard is read-only: it surfaces pending proposals
|
||||
and active agents (PRD 0019) but can't *start* an agent or
|
||||
*re-enter* one. The operator's path is split — they launch
|
||||
agents from one terminal (`./cli.py start <name>`), and watch
|
||||
them from another (`./cli.py dashboard`).
|
||||
agents from one terminal (`bot-bottle start <name>`), and watch
|
||||
them from another (`bot-bottle dashboard`).
|
||||
|
||||
This PRD collapses that split. The dashboard becomes the
|
||||
operator's single surface: pressing a key opens an agent picker,
|
||||
@@ -31,7 +31,7 @@ claude session AND the dashboard process. Exit claude → back to
|
||||
dashboard, bottle still running. Start another agent → two
|
||||
bottles up at once. Quit the dashboard → bottles continue
|
||||
running. Teardown is **always explicit**: the operator presses
|
||||
`x` on an agent, or runs `./cli.py cleanup` later.
|
||||
`x` on an agent, or runs `bot-bottle cleanup` later.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -45,7 +45,7 @@ Two real frictions today:
|
||||
open and the dashboard's "active agents" pane is hopelessly
|
||||
behind reality because they just spawned three in a row.
|
||||
|
||||
2. **`./cli.py start` ties the bottle to a single claude
|
||||
2. **`bot-bottle start` ties the bottle to a single claude
|
||||
session.** The start command's `ExitStack` brings the bottle
|
||||
up, runs claude, and tears down on Ctrl-D — fine for a one-
|
||||
shot session, wrong for "let me bounce in and out of this
|
||||
@@ -60,7 +60,7 @@ captures full-merged logs per bottle (PRD 0018). It already
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. From inside `./cli.py dashboard`, pressing `n` (new) opens
|
||||
1. From inside `bot-bottle dashboard`, pressing `n` (new) opens
|
||||
an agent picker listing every agent defined in the manifest.
|
||||
Selecting one runs `prepare → preflight → launch`.
|
||||
2. The preflight Y/N summary renders cleanly — either as a
|
||||
@@ -83,7 +83,7 @@ captures full-merged logs per bottle (PRD 0018). It already
|
||||
state cleanup) without quitting the dashboard.
|
||||
7. Quitting the dashboard (`q`) leaves every running bottle
|
||||
running. Bottle teardown is always explicit (per-bottle `x`
|
||||
or `./cli.py cleanup`). The next `./cli.py dashboard`
|
||||
or `bot-bottle cleanup`). The next `bot-bottle dashboard`
|
||||
invocation re-discovers them via `list_active_slugs()` and
|
||||
surfaces re-attach for any it can reconstruct context for
|
||||
(see "Cross-dashboard re-attach" below).
|
||||
@@ -94,13 +94,13 @@ captures full-merged logs per bottle (PRD 0018). It already
|
||||
embedded-emulator option from the research doc is out of
|
||||
scope. The handoff (option 1) is the v1; option 2 is a
|
||||
separate PRD if and when handoff is observably insufficient.
|
||||
- **Adopting bottles started by an out-of-dashboard `./cli.py
|
||||
- **Adopting bottles started by an out-of-dashboard `bot-bottle
|
||||
start` invocation.** Those have their own ExitStack-owner and
|
||||
the dashboard treats them as read-only-watch (already does
|
||||
today). Re-attach only applies to bottles the *current
|
||||
dashboard process* started.
|
||||
- **Resurrecting an out-of-process bottle into a new dashboard
|
||||
with full re-attach.** A bottle started by `./cli.py start`
|
||||
with full re-attach.** A bottle started by `bot-bottle start`
|
||||
in another terminal — or by a previous dashboard run, now
|
||||
exited — appears in the agents pane (already does, PRD 0019)
|
||||
and can be re-attached via `docker exec -it claude` because
|
||||
@@ -109,12 +109,12 @@ captures full-merged logs per bottle (PRD 0018). It already
|
||||
context object to drive teardown — e.g., the
|
||||
ExitStack-tracked CA + state cleanup `_settle_state` performs
|
||||
today. Cross-dashboard re-attach uses the existing
|
||||
`./cli.py cleanup` for teardown, not an `x` keypress (see
|
||||
`bot-bottle cleanup` for teardown, not an `x` keypress (see
|
||||
open questions).
|
||||
- **Multi-window UI.** Single curses window, two existing
|
||||
panes (proposals + agents); the agent picker is a modal, not
|
||||
a third pane.
|
||||
- **Removing `./cli.py start`.** Stays as the script-friendly /
|
||||
- **Removing `bot-bottle start`.** Stays as the script-friendly /
|
||||
legacy entry point. The dashboard is the new default.
|
||||
|
||||
## Scope
|
||||
@@ -140,7 +140,7 @@ captures full-merged logs per bottle (PRD 0018). It already
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Changes to `./cli.py start` itself. It keeps its current
|
||||
- Changes to `bot-bottle start` itself. It keeps its current
|
||||
shape; the dashboard reuses its internal pieces (backend.
|
||||
prepare / backend.launch) without reaching through the CLI
|
||||
layer.
|
||||
@@ -157,7 +157,7 @@ captures full-merged logs per bottle (PRD 0018). It already
|
||||
Today's flow:
|
||||
|
||||
```
|
||||
./cli.py start agent
|
||||
bot-bottle start agent
|
||||
└─ with backend.launch(plan) as bottle: ← bottle alive while inside `with`
|
||||
bottle.exec_agent([...], tty=True) ← blocks until claude exits
|
||||
# context exits → compose down → state cleanup
|
||||
@@ -166,7 +166,7 @@ Today's flow:
|
||||
The proposed dashboard-driven flow:
|
||||
|
||||
```
|
||||
./cli.py dashboard
|
||||
bot-bottle dashboard
|
||||
└─ bottles: dict[str, tuple[ContextManager, DockerBottle]] = {}
|
||||
|
||||
# operator presses `n`, picks agent
|
||||
@@ -205,7 +205,7 @@ Two shifts:
|
||||
evaluation, state-dir reap) doesn't fire on a quit-while-
|
||||
running bottle. It DOES fire when the operator explicitly
|
||||
stops via `x`, because that calls `cm.__exit__`. For
|
||||
bottles a previous dashboard quit on, `./cli.py cleanup`
|
||||
bottles a previous dashboard quit on, `bot-bottle cleanup`
|
||||
is the path — its compose-down + state-reap logic
|
||||
already covers the case.
|
||||
|
||||
@@ -213,7 +213,7 @@ Two shifts:
|
||||
|
||||
When the dashboard discovers a bottle in `discover_active_agents`
|
||||
that it didn't itself start (a previous-dashboard or external
|
||||
`./cli.py start` bottle), Enter still attaches via `docker exec
|
||||
`bot-bottle start` bottle), Enter still attaches via `docker exec
|
||||
-it … claude` — the agent container is running `sleep infinity`
|
||||
exactly the same way regardless of who started it. The only
|
||||
thing the current dashboard lacks for those bottles is the
|
||||
@@ -221,8 +221,8 @@ launch-context object needed to drive a clean teardown via
|
||||
`x`.
|
||||
|
||||
For v1 we surface this honestly: pressing `x` on a non-owned
|
||||
agent shows a status hint pointing at `./cli.py cleanup` (or
|
||||
`./cli.py cleanup` targeted at the slug if we add that flag
|
||||
agent shows a status hint pointing at `bot-bottle cleanup` (or
|
||||
`bot-bottle cleanup` targeted at the slug if we add that flag
|
||||
later). The agent stays alive; the operator handles teardown
|
||||
out-of-band. Enter (re-attach) works for both owned and
|
||||
non-owned bottles.
|
||||
@@ -288,7 +288,7 @@ agents pane.
|
||||
|
||||
`x` on a non-owned agent (discovered via `list_active_slugs`
|
||||
but not in `bottles` dict): no-op with status hint pointing
|
||||
at `./cli.py cleanup` (the existing path that tears down
|
||||
at `bot-bottle cleanup` (the existing path that tears down
|
||||
ANY bot-bottle compose project plus reaps state dirs).
|
||||
|
||||
### Dashboard quit
|
||||
@@ -300,7 +300,7 @@ the `docker compose` project keeps running. The next dashboard
|
||||
invocation discovers the bottles via `list_active_slugs` and
|
||||
surfaces re-attach.
|
||||
|
||||
This is a real departure from today's `./cli.py start`
|
||||
This is a real departure from today's `bot-bottle start`
|
||||
semantics (which couples bottle lifetime to the process via
|
||||
ExitStack). It's intentional: the dashboard is a watching +
|
||||
acting surface, not a lifetime owner.
|
||||
@@ -322,7 +322,7 @@ Sized for one PR each.
|
||||
dashboard's ExitStack; handoff invokes `attach_agent`.
|
||||
3. **Re-attach via Enter on owned agents-pane row.** Looks up
|
||||
the slug in the dashboard's `bottles` map; if present →
|
||||
handoff; else → status-line hint pointing at `./cli.py
|
||||
handoff; else → status-line hint pointing at `bot-bottle
|
||||
resume`.
|
||||
4. **Explicit per-bottle stop (`x` keybinding).** Pop the
|
||||
bottle's `close` callback off the stack, call it, refresh.
|
||||
@@ -369,7 +369,7 @@ Sized for one PR each.
|
||||
bottles dict goes out of scope without invoking `__exit__`,
|
||||
so the `docker compose` projects keep running. Bottle
|
||||
teardown is always explicit: per-bottle `x` (for
|
||||
dashboard-owned), or `./cli.py cleanup` (for everything).
|
||||
dashboard-owned), or `bot-bottle cleanup` (for everything).
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ window, two panes, no terminal handoff.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. When the operator runs `./cli.py dashboard` from inside a
|
||||
1. When the operator runs `bot-bottle dashboard` from inside a
|
||||
tmux session (`$TMUX` set), the dashboard establishes a
|
||||
two-pane layout: dashboard in the left pane, an initially-
|
||||
empty right pane reserved for claude sessions.
|
||||
@@ -313,7 +313,7 @@ Sized small.
|
||||
3. **Dashboard launched OUTSIDE tmux but tmux is installed.**
|
||||
Should the dashboard auto-exec itself inside a fresh tmux
|
||||
session to get the split-pane experience? Convenient but
|
||||
surprising (`./cli.py dashboard` shouldn't silently
|
||||
surprising (`bot-bottle dashboard` shouldn't silently
|
||||
change what session you're in). v1 leaves this off —
|
||||
operators who want split-pane mode start tmux themselves
|
||||
and then run the dashboard.
|
||||
@@ -332,7 +332,7 @@ Sized small.
|
||||
PRD-0019 focus indicator?
|
||||
|
||||
6. **Concurrent dashboards in different tmux windows.**
|
||||
Multiple `./cli.py dashboard` invocations in different
|
||||
Multiple `bot-bottle dashboard` invocations in different
|
||||
tmux windows would each create their own right pane —
|
||||
probably fine, each has its own state, but worth
|
||||
verifying that `tmux list-panes` is scoped to the right
|
||||
|
||||
@@ -14,7 +14,7 @@ Today bot-bottle is hard-wired around Claude Code assumptions. When Claude runs
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- A Codex agent can be started from the dashboard and via `./cli.py start` alongside a Claude agent.
|
||||
- A Codex agent can be started from the dashboard and via `bot-bottle start` alongside a Claude agent.
|
||||
- The manifest can express the agent provider/template and, where needed, a custom agent Dockerfile.
|
||||
- Claude-specific default egress/auth behavior is no longer implicit; provider-specific auth is expressed through explicit bottle egress routes and roles.
|
||||
- The launcher preserves required infrastructure behavior for sidecars, egress, pipelock, supervisor MCP, CA handling, git, and shell basics.
|
||||
|
||||
@@ -30,7 +30,7 @@ across every bottle spin-up. This has several consequences:
|
||||
to grant that access.
|
||||
- **Manual rotation burden.** Operators must manage key files on disk, keeping
|
||||
them secure, rotating them on a schedule, and distributing them across hosts
|
||||
that run `./cli.py start`.
|
||||
that run `bot-bottle start`.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Summary
|
||||
|
||||
The `./cli.py dashboard` command has grown from its PRD 0013 roots
|
||||
The `bot-bottle dashboard` command has grown from its PRD 0013 roots
|
||||
(triage supervise proposals) into a parallel-agent control surface
|
||||
(PRDs 0019/0020/0021): an active-agents pane, agent picker + start,
|
||||
re-attach, per-bottle stop, tmux split-pane handoff, operator-
|
||||
@@ -21,7 +21,7 @@ proposals, approve / modify / reject each one, write audit entries,
|
||||
deliver the response that unblocks the agent's tool call. Everything
|
||||
that's about *starting / re-entering / stopping* bottles, or about
|
||||
*operator-initiated* config edits, comes out. The command is renamed
|
||||
`./cli.py supervise` so the name matches what it does after the cut.
|
||||
`bot-bottle supervise` so the name matches what it does after the cut.
|
||||
|
||||
Future agent-management UX is explicitly punted: if and when a
|
||||
control surface for parallel agents resurfaces, the working
|
||||
@@ -41,9 +41,9 @@ Three concrete pains, all downstream of the dashboard's growth:
|
||||
ExitStack-free bottle ownership are intricate enough that
|
||||
shipping the next polish increment costs more than it returns.
|
||||
2. **No clear ownership of "starts and stops bottles".** Today
|
||||
that responsibility is split: `./cli.py start` owns one-shot
|
||||
that responsibility is split: `bot-bottle start` owns one-shot
|
||||
sessions; the dashboard owns multi-session bottles it started
|
||||
itself; `./cli.py cleanup` owns everything else. The dashboard
|
||||
itself; `bot-bottle cleanup` owns everything else. The dashboard
|
||||
tracking its own `bottles: dict[str, (cm, bottle, identity)]`
|
||||
that doesn't survive a quit is a confusing third lane.
|
||||
3. **Wrong target shape for a "manage many agents" UI.** The
|
||||
@@ -97,12 +97,12 @@ problem is everything that got bolted onto that core after.
|
||||
dashboard. After this PRD they don't exist anywhere — operators
|
||||
who need ad-hoc edits use the same path the agents do (call the
|
||||
supervise tool from inside the bottle) or hand-edit the host-
|
||||
side files and restart the sidecar. Adding a `./cli.py routes
|
||||
side files and restart the sidecar. Adding a `bot-bottle routes
|
||||
edit <slug>` verb is a follow-up if the loss bites.
|
||||
- **Removing `./cli.py start` or changing its semantics.** Start
|
||||
- **Removing `bot-bottle start` or changing its semantics.** Start
|
||||
remains the one-shot launch path. PRD 0020's bottle-outlives-
|
||||
process model is removed; the only path to a long-running
|
||||
bottle is `./cli.py start` (foreground) plus `cli.py cleanup`
|
||||
bottle is `bot-bottle start` (foreground) plus `cli.py cleanup`
|
||||
for teardown.
|
||||
- **Removing the supervise-sidecar protocol or any of the three
|
||||
block-remediation engines.** PRDs 0013–0016 stay Active. The
|
||||
@@ -122,8 +122,8 @@ problem is everything that got bolted onto that core after.
|
||||
|
||||
### In scope
|
||||
|
||||
- **Rename the subcommand.** `./cli.py dashboard` becomes
|
||||
`./cli.py supervise`. The module moves from `bot_bottle/cli/
|
||||
- **Rename the subcommand.** `bot-bottle dashboard` becomes
|
||||
`bot-bottle supervise`. The module moves from `bot_bottle/cli/
|
||||
dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher
|
||||
in `bot_bottle/cli/__init__.py` and the help text both update.
|
||||
- **Strip the curses loop to proposal-only.** The remaining
|
||||
@@ -167,7 +167,7 @@ problem is everything that got bolted onto that core after.
|
||||
|
||||
- Any new feature in the supervise TUI. The cut is purely
|
||||
subtractive (except for the rename).
|
||||
- Behavior changes in `./cli.py start`, `cli.py cleanup`,
|
||||
- Behavior changes in `bot-bottle start`, `cli.py cleanup`,
|
||||
`cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`,
|
||||
`cli.py init` — unchanged.
|
||||
- Changes to the supervise sidecar (`supervise_server.py`,
|
||||
@@ -181,7 +181,7 @@ problem is everything that got bolted onto that core after.
|
||||
|
||||
### Final shape of the TUI
|
||||
|
||||
After this PRD the `./cli.py supervise` curses surface is:
|
||||
After this PRD the `bot-bottle supervise` curses surface is:
|
||||
|
||||
```
|
||||
bot-bottle supervise (3 pending)
|
||||
@@ -307,8 +307,8 @@ The PR closes issue #174.
|
||||
|
||||
1. **`e` / `p` operator-initiated edits — gone for good or
|
||||
moved to a separate CLI verb?** The PRD removes them with no
|
||||
replacement. The simplest replacement is `./cli.py routes
|
||||
edit <slug>` and `./cli.py pipelock edit <slug>`, sharing
|
||||
replacement. The simplest replacement is `bot-bottle routes
|
||||
edit <slug>` and `bot-bottle pipelock edit <slug>`, sharing
|
||||
the existing `apply_routes_change` / `apply_allowlist_change`
|
||||
engines. If the loss is felt within the first parallel
|
||||
run after this lands, that follow-up is a small PR. Leaving
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
## Summary
|
||||
|
||||
When `./cli.py start` is run without an agent name, or without a backend
|
||||
When `bot-bottle start` is run without an agent name, or without a backend
|
||||
explicitly specified, the user currently gets an argparse error (missing
|
||||
positional) or falls through to the `docker` default silently. This PRD
|
||||
adds a terminal UI that appears in those gaps: a filter-select screen
|
||||
built with `curses` that lets the operator pick the agent and/or backend
|
||||
interactively rather than memorising names or consulting `./cli.py list`.
|
||||
interactively rather than memorising names or consulting `bot-bottle list`.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -29,15 +29,15 @@ visible.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `./cli.py start` (no arguments) shows an interactive agent selector;
|
||||
1. `bot-bottle start` (no arguments) shows an interactive agent selector;
|
||||
the selected name is used exactly as if it had been passed on the
|
||||
command line.
|
||||
2. `./cli.py start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
|
||||
2. `bot-bottle start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
|
||||
shows an interactive backend selector; the selected backend is used
|
||||
exactly as if `--backend=<selected>` had been passed.
|
||||
3. `./cli.py start <name> --backend=<b>` (both explicit) shows neither
|
||||
3. `bot-bottle start <name> --backend=<b>` (both explicit) shows neither
|
||||
screen — no behavioural change from today.
|
||||
4. `./cli.py start` (no arguments, no env backend) shows the agent
|
||||
4. `bot-bottle start` (no arguments, no env backend) shows the agent
|
||||
selector first, then the backend selector.
|
||||
5. The filter-select widget is a standalone utility
|
||||
(`bot_bottle/cli/tui.py`) shared by both selectors.
|
||||
@@ -57,7 +57,7 @@ visible.
|
||||
- No pagination beyond what fits in the terminal window (scroll via
|
||||
cursor movement is sufficient for typical agent counts).
|
||||
- No multi-select; exactly one item is chosen per invocation.
|
||||
- No changes to `./cli.py resume`, `./cli.py list`, or any other
|
||||
- No changes to `bot-bottle resume`, `bot-bottle list`, or any other
|
||||
subcommand.
|
||||
|
||||
## Design
|
||||
@@ -83,7 +83,7 @@ def filter_select(
|
||||
|
||||
The widget renders to the tty file descriptor opened via `curses.initscr`
|
||||
(or `curses.newterm` on the tty fd so stdout remains clean for callers
|
||||
that pipe `./cli.py`).
|
||||
that pipe `bot-bottle`).
|
||||
|
||||
Layout (full-width, minimal):
|
||||
|
||||
@@ -140,7 +140,7 @@ agent picker can populate itself from the real manifest. The same
|
||||
`filter_select` opens `/dev/tty` and feeds it as the input file to
|
||||
`curses.wrapper`-equivalent code (using `curses.newterm` to avoid
|
||||
clobbering the caller's stdout/stderr). This keeps the picker
|
||||
composable — callers can pipe `./cli.py` output without the curses
|
||||
composable — callers can pipe `bot-bottle` output without the curses
|
||||
draw sequences contaminating the pipe.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
@@ -30,12 +30,12 @@ snapshot before a planned host reboot or hardware migration.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and
|
||||
- `bot-bottle commit [<slug>]` takes a snapshot of the running agent and
|
||||
stores it as a local artifact.
|
||||
- Without a slug argument the command shows the same interactive picker
|
||||
as `start` (the list of active slugs).
|
||||
- The committed artifact reference is stored in per-bottle state so
|
||||
that the next `./cli.py resume <slug>` automatically uses the
|
||||
that the next `bot-bottle resume <slug>` automatically uses the
|
||||
snapshot instead of rebuilding from the Dockerfile.
|
||||
- `mark_preserved` is called so the state dir survives the normal
|
||||
session-end cleanup.
|
||||
@@ -81,7 +81,7 @@ to the committed `.smolmachine` artifact.
|
||||
### `commit` command
|
||||
|
||||
```
|
||||
./cli.py commit [<slug>]
|
||||
bot-bottle commit [<slug>]
|
||||
```
|
||||
|
||||
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
|
||||
|
||||
@@ -37,7 +37,7 @@ egress policy), the operator must duplicate the agent file and change the
|
||||
selection order, as the effective bottle for the session.
|
||||
5. Confirming with an empty selection falls back to the agent's `bottle:` field.
|
||||
If neither is set, a ManifestError is raised pointing the operator at the fix.
|
||||
6. The ordered bottle list is stored in launch metadata so `./cli.py resume`
|
||||
6. The ordered bottle list is stored in launch metadata so `bot-bottle resume`
|
||||
uses the same bottles.
|
||||
7. The preflight summary (`y/N` screen) shows the effective bottle name(s).
|
||||
8. The multi-select picker supports incremental filtering, Space/Enter to toggle
|
||||
@@ -52,7 +52,7 @@ egress policy), the operator must duplicate the agent file and change the
|
||||
- Reordering the selection list from within the picker (order = insertion order;
|
||||
drag-and-drop is out of scope).
|
||||
- Storing bottle selection history / MRU.
|
||||
- Changes to `./cli.py edit`, `./cli.py list`, or `./cli.py info`.
|
||||
- Changes to `bot-bottle edit`, `bot-bottle list`, or `bot-bottle info`.
|
||||
- Removing the `bottle:` key from the agent schema (it stays, now optional).
|
||||
|
||||
## Design
|
||||
|
||||
@@ -74,7 +74,7 @@ macOS-only for v1. Three concrete blockers:
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` launches,
|
||||
- `BOT_BOTTLE_BACKEND=smolmachines bot-bottle start <agent>` launches,
|
||||
runs, and tears down a bottle on a Linux host with `/dev/kvm`.
|
||||
- The TSI allowlist is enforced on Linux: PRD 0022's
|
||||
`tests/integration/test_sandbox_escape.py` passes against
|
||||
|
||||
@@ -39,7 +39,7 @@ end-to-end runner that would catch the *next* macOS-only launch regression.)
|
||||
PR #470 (#414) already made the integration suite backend-agnostic:
|
||||
`skip_unless_selected_backend_available()` gates on the *selected* backend's
|
||||
own `is_backend_ready()` rather than `docker_available()`, and each
|
||||
integration job runs `./cli.py backend status --backend=<name>` as a preflight
|
||||
integration job runs `bot-bottle backend status --backend=<name>` as a preflight
|
||||
that fails loudly when the backend is missing. That is the machinery this job
|
||||
plugs into; this PRD supplies the runner and the job.
|
||||
|
||||
@@ -98,7 +98,7 @@ Modeled on `integration-firecracker`:
|
||||
- `concurrency: { group: integration-macos-infra, cancel-in-progress: false }`
|
||||
to serialize runs against the singleton.
|
||||
- **Preflight** — `command -v container`, `container system status`, then
|
||||
`./cli.py backend status --backend=macos-container`; any failure exits
|
||||
`bot-bottle backend status --backend=macos-container`; any failure exits
|
||||
non-zero so a misprovisioned runner fails loudly instead of silently
|
||||
skipping.
|
||||
- Run the integration suite under coverage with
|
||||
|
||||
@@ -16,7 +16,7 @@ verifies host prerequisites after install.
|
||||
## Problem
|
||||
|
||||
There is currently no install path for new users. The only way to run
|
||||
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any
|
||||
bot-bottle is to clone the repo and invoke `bot-bottle`. This blocks any
|
||||
public demo: readers want `curl | sh` or `pipx install`, not a manual
|
||||
clone-and-configure flow. There is also no single command that tells a
|
||||
user whether their host is actually ready to run a bottle.
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# PRD 0083: Packaged infrastructure artifacts
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Codex
|
||||
- **Created:** 2026-07-27
|
||||
- **Issue:** #536
|
||||
|
||||
## Summary
|
||||
|
||||
Publish the orchestrator and gateway infrastructure before packaging
|
||||
bot-bottle, record their immutable identities in the application package, and
|
||||
make every production backend acquire those exact artifacts instead of
|
||||
building infrastructure during bottle startup. Docker and Apple Container pull
|
||||
digest-pinned OCI images; Firecracker pulls checksum-verified rootfs artifacts
|
||||
selected by the same packaged release manifest.
|
||||
|
||||
## Problem
|
||||
|
||||
Starting a bottle currently invokes an infrastructure build on Docker and
|
||||
Apple Container even when a healthy orchestrator is already running. Both
|
||||
infra composers call `ensure_built()` before their health/currentness checks,
|
||||
and both implementations unconditionally invoke their OCI builder. Layer
|
||||
caching can make this less expensive, but startup still depends on a working
|
||||
builder, build context, base-image registry, and package installation network.
|
||||
|
||||
The Apple Container orchestrator also bind-mounts the installed source over the
|
||||
package baked into its image. A pinned image alone would therefore not pin the
|
||||
code that actually runs.
|
||||
|
||||
Firecracker already downloads fixed rootfs artifacts, but it derives their
|
||||
versions from the package contents at launch. There is no package-level record
|
||||
that says which already-published infrastructure release belongs to an
|
||||
installed bot-bottle release.
|
||||
|
||||
The result is three different delivery contracts:
|
||||
|
||||
- Docker and Apple Container build mutable `:latest` images on the launch host.
|
||||
- Firecracker computes an artifact version locally and downloads it.
|
||||
- An installed wheel contains Dockerfiles specifically so production startup
|
||||
can rebuild its own infrastructure.
|
||||
|
||||
That makes startup slower and less reliable, prevents a packaged application
|
||||
from identifying the infrastructure it was tested with, and weakens the
|
||||
supply-chain boundary between release production and runtime.
|
||||
|
||||
## Goals / Success criteria
|
||||
|
||||
- A packaged bot-bottle release contains one validated release manifest with
|
||||
immutable orchestrator and gateway identities.
|
||||
- Docker and Apple Container acquire and run the manifest's digest-pinned OCI
|
||||
images without invoking `docker build` or `container build`.
|
||||
- The shipped Claude, Codex, and Pi agent images are built, smoke-tested,
|
||||
published, and selected by digest as part of the same commit bundle.
|
||||
- Firecracker acquires the manifest's versioned, checksum-verified
|
||||
orchestrator and gateway rootfs artifacts rather than deriving versions at
|
||||
launch.
|
||||
- All three backends expose the same backend-neutral `ensure_available`
|
||||
lifecycle contract.
|
||||
- A healthy running orchestrator is retained only when its recorded release
|
||||
identity matches the installed package.
|
||||
- Apple Container runs the package baked into the orchestrator image; no host
|
||||
source overlays production code.
|
||||
- Missing, mutable, malformed, or internally inconsistent release metadata
|
||||
fails closed before infrastructure starts.
|
||||
- Source-checkout development retains an explicit local-build mode.
|
||||
- Publishing produces the infrastructure first and the installable Python
|
||||
package last, so the package cannot name artifacts that were not published.
|
||||
- Any branch, tag, or commit can be resolved once to a full source SHA and
|
||||
published as one immutable, commit-addressed artifact bundle.
|
||||
- The installer defaults to the production channel, can select staging or an
|
||||
exact commit, and warns for commit snapshots that have not been qualified as
|
||||
a release.
|
||||
- Staging and production releases promote the same tested artifact bytes;
|
||||
promotion never rebuilds them.
|
||||
- A release is published only after the complete pre-release suite and a clean
|
||||
install from the published bundle succeed.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Prebuilding user-supplied Dockerfiles. Custom agent images continue to build
|
||||
through the backend-specific agent-image paths.
|
||||
- Installing Docker, Apple Container, Firecracker, or other host prerequisites.
|
||||
- Replacing Gitea's OCI or generic-package registries.
|
||||
- Adding image signing or a transparency log. Immutable digests and existing
|
||||
Firecracker SHA-256 verification are the integrity boundary for this version.
|
||||
- Forcing a network download when an immutable, verified artifact is already
|
||||
cached locally. "Pull" means acquire the packaged identity, with safe cache
|
||||
reuse.
|
||||
- Supporting live source overlays in packaged production installs.
|
||||
- Defining the project's version-numbering policy beyond stable
|
||||
`vX.Y.Z` and staging `vX.Y.Z-rc.N` tag shapes.
|
||||
|
||||
## Design
|
||||
|
||||
### Release manifest
|
||||
|
||||
`bot_bottle/release-manifest.json` is generated during package production and
|
||||
shipped as package data:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"source_commit": "<40 lowercase hex>",
|
||||
"oci": {
|
||||
"orchestrator": "gitea.dideric.is/didericis/bot-bottle-orchestrator@sha256:<64 hex>",
|
||||
"gateway": "gitea.dideric.is/didericis/bot-bottle-gateway@sha256:<64 hex>",
|
||||
"agent_claude": "gitea.dideric.is/didericis/bot-bottle-claude@sha256:<64 hex>",
|
||||
"agent_codex": "gitea.dideric.is/didericis/bot-bottle-codex@sha256:<64 hex>",
|
||||
"agent_pi": "gitea.dideric.is/didericis/bot-bottle-pi@sha256:<64 hex>"
|
||||
},
|
||||
"firecracker": {
|
||||
"orchestrator": {
|
||||
"version": "<artifact version>",
|
||||
"sha256": "<rootfs.ext4.gz sha256>"
|
||||
},
|
||||
"gateway": {
|
||||
"version": "<artifact version>",
|
||||
"sha256": "<rootfs.ext4.gz sha256>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The runtime loader accepts only schema 1, digest-qualified OCI references,
|
||||
non-empty Firecracker versions, 64-character lowercase SHA-256 values, and a
|
||||
full source commit. It returns immutable value objects rather than passing raw
|
||||
dictionaries through backend code.
|
||||
|
||||
The repository carries no pretend production pins. A source checkout or ad-hoc
|
||||
Git wheel carries a `development: true` marker and selects local infrastructure
|
||||
builds, preserving the contributor and current `install.sh` paths. The release
|
||||
packaging workflow must replace that marker with the validated schema above;
|
||||
it refuses mutable or incomplete release inputs.
|
||||
|
||||
### Packaging order
|
||||
|
||||
The release job operates on one tested commit:
|
||||
|
||||
1. Build and smoke-test the multi-architecture orchestrator, gateway, Claude,
|
||||
Codex, and Pi OCI images.
|
||||
2. Push them and resolve their registry manifest digests.
|
||||
3. Build Firecracker's orchestrator and gateway rootfs artifacts from the same
|
||||
source and pinned OCI bases.
|
||||
4. Publish both generic-package artifacts and retain their versions and
|
||||
compressed-file checksums.
|
||||
5. Generate `release-manifest.json` from those published identities.
|
||||
6. Build the wheel with that manifest. Do not produce a source distribution:
|
||||
rebuilding an sdist outside this release boundary could replace the pins
|
||||
with the development manifest.
|
||||
7. Install the wheel in a clean environment and assert that every manifest
|
||||
identity is valid and retrievable.
|
||||
8. Publish only that verified wheel.
|
||||
|
||||
The build hook receives the manifest as a file input. It must not query a
|
||||
registry or choose versions itself: package builds stay deterministic and
|
||||
network-independent once their release inputs exist.
|
||||
|
||||
### Commit-addressed bundle index
|
||||
|
||||
Every publication resolves its requested branch, tag, or commit to one
|
||||
40-character source SHA before doing any work. The publisher writes an
|
||||
immutable external bundle index under that SHA. The index contains:
|
||||
|
||||
- schema version and source SHA;
|
||||
- wheel URL and SHA-256;
|
||||
- orchestrator and gateway OCI digest references;
|
||||
- Claude, Codex, and Pi agent OCI digest references;
|
||||
- Firecracker package versions and compressed-file SHA-256 values;
|
||||
- producing workflow/run identity and publication timestamp; and
|
||||
- producing workflow provenance. Qualification is recorded separately in an
|
||||
immutable tag pointer so the commit bundle itself never changes.
|
||||
|
||||
The embedded wheel manifest contains the runtime subset of the same identities.
|
||||
Publication fails if the embedded manifest and external index differ.
|
||||
|
||||
Bundle coordinates are immutable. Re-running publication for a SHA verifies
|
||||
and reuses a byte-identical complete bundle; it never replaces one artifact or
|
||||
mixes outputs from different runs. A partial existing bundle is an error and
|
||||
requires an explicit administrative cleanup before retrying.
|
||||
|
||||
Commit bundles are durable release inputs, not expiring CI artifacts. The
|
||||
project may garbage-collect unqualified snapshots only under a documented
|
||||
retention policy; qualified staging and production bundles are retained.
|
||||
|
||||
### Branches, tags, and promotion
|
||||
|
||||
The protected promotion topology is:
|
||||
|
||||
```text
|
||||
feature -> main -> staging -> production
|
||||
| |
|
||||
vX.Y.Z-rc.N vX.Y.Z
|
||||
```
|
||||
|
||||
Changes reach `staging` and `production` through promotion pull requests; those
|
||||
branches do not accept direct development commits. A staging tag must point to
|
||||
a commit reachable from `staging` and uses `vX.Y.Z-rc.N`. A production tag must
|
||||
point to a commit reachable from `production` and uses `vX.Y.Z`.
|
||||
|
||||
Channels are small, mutable pointers to immutable qualified bundles:
|
||||
|
||||
- `production` points to the newest promoted stable tag;
|
||||
- `staging` points to the newest promoted release-candidate tag; and
|
||||
- `main` is not a release channel; its commits are installable snapshots.
|
||||
|
||||
Channel updates are serialized and monotonic. Moving a channel to an older
|
||||
release requires a distinct, explicit rollback operation that records the
|
||||
reason and target. Deleting or moving a Git tag does not mutate a published
|
||||
bundle or channel silently.
|
||||
|
||||
Promotion reuses the exact bundle selected by source SHA. It does not rebuild
|
||||
the wheel, OCI images, or Firecracker artifacts. Thus production runs the same
|
||||
bytes qualified in staging.
|
||||
|
||||
### Publication workflows
|
||||
|
||||
`publish-artifacts` is manually dispatchable for any branch, tag, or commit.
|
||||
It resolves the input to a source SHA, checks out that SHA, builds and publishes
|
||||
all infrastructure artifacts, generates the bundle index and embedded
|
||||
manifest, builds the wheel, installs the wheel in a clean environment, and
|
||||
publishes the verified wheel. It does not create a release or update staging or
|
||||
production. Its output is an unqualified snapshot and publication is
|
||||
serialized per source SHA.
|
||||
|
||||
After required CI succeeds, `main` may invoke the same publication operation
|
||||
automatically. Manual publication remains available so a branch can be tested
|
||||
before merge. Automatic and manual runs converge on the same idempotent bundle.
|
||||
|
||||
The release workflow is triggered by an eligible staging or production tag:
|
||||
|
||||
1. Resolve and validate the tag, source SHA, tag shape, and branch reachability.
|
||||
2. Run the complete pre-release suite against that SHA, including Docker,
|
||||
Firecracker, and Apple Container.
|
||||
3. After the suite passes, ensure the immutable commit bundle exists. Build it
|
||||
then if absent; otherwise verify every existing identity and checksum.
|
||||
4. Install using the published installer and bundle index on clean supported
|
||||
hosts, and run the post-install smoke test against the acquired artifacts.
|
||||
5. Publish an immutable qualified tag pointer, create the Gitea release, and
|
||||
advance the matching channel pointer.
|
||||
|
||||
No release artifact or channel update occurs before qualification succeeds.
|
||||
Jobs use per-SHA and per-channel concurrency locks so concurrent dispatches
|
||||
cannot publish twice or race a promotion.
|
||||
|
||||
### Installer selection and warnings
|
||||
|
||||
The installer installs the latest `production` channel by default. It accepts:
|
||||
|
||||
- `BOT_BOTTLE_CHANNEL=production` or `staging` to select the latest release in
|
||||
that channel;
|
||||
- `BOT_BOTTLE_REF=<40-character-sha>` to install an exact commit bundle; and
|
||||
- `BOT_BOTTLE_VERSION=<tag>` to install an exact qualified release.
|
||||
|
||||
Selectors are mutually exclusive. Branch names are accepted by the publication
|
||||
workflow but not by the installer because they are mutable; callers resolve
|
||||
and publish them first, then install the reported SHA.
|
||||
|
||||
An exact-commit install prints a prominent warning that the snapshot may not
|
||||
have passed release qualification, even if the same SHA is later associated
|
||||
with a tag. A tag or channel install suppresses that warning only when its
|
||||
validated pointer is marked qualified and selects the same immutable bundle
|
||||
SHA.
|
||||
|
||||
The installer downloads the wheel named by the external index, verifies its
|
||||
SHA-256 before invoking pipx or pip, and reports the selected channel/tag,
|
||||
source SHA, and bundle identity. It never builds a Git checkout for a packaged
|
||||
install and never trusts a mutable channel response without resolving it to an
|
||||
immutable bundle index.
|
||||
|
||||
### Backend-neutral lifecycle
|
||||
|
||||
Rename the host-side control-plane lifecycle operation from `ensure_built()` to
|
||||
`ensure_available()`. The production meaning is "make the package-selected
|
||||
artifact locally available and verified." Infrastructure composers call it
|
||||
before starting either plane.
|
||||
|
||||
The gateway receives the same contract so startup does not continue to rebuild
|
||||
the adjacent half of the fixed infrastructure pair.
|
||||
|
||||
A source checkout is itself a development boundary and selects the existing
|
||||
builders. `BOT_BOTTLE_INFRA_BUILD=local` provides the same explicit override
|
||||
for release debugging. Both paths deliberately bypass release metadata.
|
||||
Packaged production startup never silently falls back from a failed pull to a
|
||||
local build.
|
||||
|
||||
### Docker
|
||||
|
||||
The Docker backend runs `docker pull` with each digest-qualified manifest
|
||||
reference. Docker may reuse its content-addressed local store. Containers are
|
||||
created from the immutable reference, and the running container's image ID is
|
||||
compared with the locally resolved ID for currentness.
|
||||
|
||||
The orchestrator source-hash label is replaced by a release-identity label.
|
||||
The label is diagnostic; the image ID comparison remains authoritative.
|
||||
|
||||
### Apple Container
|
||||
|
||||
The Apple Container backend pulls the same multi-architecture OCI references.
|
||||
Because Apple Container's accepted syntax for pull, tag, inspect, and run is
|
||||
not identical to Docker's, one utility owns normalization from a digest
|
||||
reference to the local runnable name. Integration coverage on the macOS runner
|
||||
guards that adapter.
|
||||
|
||||
Production orchestrator launch removes the build-root bind mount, `PYTHONPATH`,
|
||||
and `BOT_BOTTLE_SOURCE_HASH`. It runs only the code baked into the pulled
|
||||
image. Currentness is based on the packaged release identity/image ID.
|
||||
|
||||
### Firecracker
|
||||
|
||||
The publishing tool may continue computing content-derived artifact versions;
|
||||
that is a producer concern. Runtime launch instead reads the version and
|
||||
expected compressed checksum from the release manifest.
|
||||
|
||||
The artifact cache validates the packaged expected checksum even when a
|
||||
previous `.verified` marker exists. The marker records the checksum it
|
||||
validated, rather than an unqualified `ok`, so changing package metadata cannot
|
||||
adopt an artifact verified against a different expectation.
|
||||
|
||||
The existing candidate-directory and explicit local-build paths remain for CI
|
||||
and development. Candidate metadata must match the packaged identity unless
|
||||
the caller selected local development mode.
|
||||
|
||||
### Overrides and failure behavior
|
||||
|
||||
`BOT_BOTTLE_ORCHESTRATOR_IMAGE` and `BOT_BOTTLE_GATEWAY_IMAGE` remain useful
|
||||
for testing but production accepts them only when they are digest-qualified.
|
||||
Mutable tag overrides require explicit local-build mode.
|
||||
|
||||
Artifact acquisition errors identify the packaged reference and backend. No
|
||||
backend catches an acquisition failure and rebuilds from local source.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- Unit-test manifest parsing, immutability validation, missing-manifest errors,
|
||||
package-data inclusion, and deterministic build-hook injection.
|
||||
- Assert Docker and Apple production preparation pulls and never builds.
|
||||
- Assert explicit local mode retains existing build behavior.
|
||||
- Assert the Apple orchestrator command has no source bind mount or
|
||||
`PYTHONPATH`.
|
||||
- Assert running-image mismatches recreate infrastructure while matching,
|
||||
healthy instances are retained.
|
||||
- Assert Firecracker uses packaged versions/checksums and rejects mismatches in
|
||||
downloaded, cached, and candidate artifacts.
|
||||
- Extend wheel-install coverage to inspect the installed manifest.
|
||||
- Run the full unit and Docker integration gates; run advisory Firecracker and
|
||||
macOS integration before publishing a release.
|
||||
- Test ref resolution, tag-shape and branch-reachability enforcement, bundle
|
||||
idempotency, partial-publication rejection, and per-SHA concurrency.
|
||||
- Test installer channel, tag, and exact-commit selection; checksum rejection;
|
||||
mutually exclusive selectors; and snapshot warning behavior.
|
||||
- Test that release qualification happens before publication and that a failed
|
||||
suite or clean-install smoke test cannot create a release or move a channel.
|
||||
- Test promotion reuses the qualified bundle byte-for-byte and that monotonic
|
||||
channel movement rejects an accidental rollback.
|
||||
|
||||
## Rollout
|
||||
|
||||
Land runtime, installer, bundle publication, and release qualification support
|
||||
together, but do not move the production installer away from its current
|
||||
source-install path until an end-to-end staging release has passed on Docker,
|
||||
Firecracker, and Apple Container.
|
||||
|
||||
Create and protect `staging` and `production`, then promote one commit from
|
||||
`main` through both branches. Publish its commit bundle, qualify an RC tag,
|
||||
perform clean installs through the staging channel, promote the same SHA, and
|
||||
qualify a stable tag. Until that first stable qualification exists, the
|
||||
installer's production default fails closed because there is no production
|
||||
pointer. The stable promotion must demonstrate that every production artifact
|
||||
identity matches the staging-qualified bundle.
|
||||
|
||||
Existing source checkouts use explicit local mode. Existing mutable local
|
||||
images are ignored by production acquisition and may be pruned independently.
|
||||
Snapshot retention and rollback procedures must be documented before automatic
|
||||
publication from every successful `main` commit is enabled.
|
||||
@@ -32,9 +32,17 @@ not a principled scope exclusion: both are major hosted sandbox platforms and
|
||||
belong in this landscape even though they target platform builders rather than
|
||||
bot-bottle's local single-operator workflow.
|
||||
|
||||
Updated 2026-07-27 after a scan of recent Show HN launches: **Black LLAB,
|
||||
Eve, CloudRouter, Nucleus, yolo-cage, and Sandbox Agent SDK** added as a
|
||||
dated entrant cohort. They sharpen the comparison on three axes the original
|
||||
table underweighted: the browser/preview loop, parallel-agent operator UX, and
|
||||
a provider-neutral automation/session API.
|
||||
|
||||
## Summary
|
||||
|
||||
The main table compares bot-bottle against fifteen isolation/sandbox tools.
|
||||
The main table compares bot-bottle against fifteen canonical
|
||||
isolation/sandbox tools; a later section evaluates six recent HN entrants
|
||||
without widening an already unwieldy table.
|
||||
Governance/pre-action authorization and credential-only layers are covered
|
||||
separately because they don't provide VM or container isolation. None
|
||||
duplicate bot-bottle's combination of local
|
||||
@@ -542,6 +550,199 @@ them.
|
||||
framework runtime is not compromised.
|
||||
- **Maturity**: Specification + reference implementation, 2026.
|
||||
|
||||
## Recent HN entrants (added 2026-07-27)
|
||||
|
||||
These are grouped by launch date rather than promoted into the main table.
|
||||
Several are young or sparsely documented, and putting them beside mature
|
||||
runtime platforms with false precision would obscure the useful comparison.
|
||||
The HN launch posts are the evidence snapshot; feature claims should be
|
||||
rechecked against their repositories before relying on them for a security
|
||||
decision.
|
||||
|
||||
### Black LLAB
|
||||
|
||||
- **Source**: https://github.com/isaacdear/black-llab ;
|
||||
HN launch https://news.ycombinator.com/item?id=47402394
|
||||
- **Isolation/locality**: Local Docker environment, with an isolated container
|
||||
created for each agent task. Shared host kernel; no stronger boundary is
|
||||
claimed.
|
||||
- **Agent integration**: General local/cloud model workspace. Its headline is
|
||||
dynamic routing of simple prompts to local models and complex prompts to
|
||||
hosted models, with code execution and web scraping inside the task
|
||||
container.
|
||||
- **Network/credentials**: No default-deny egress, payload inspection, or
|
||||
host-side credential injection documented in the launch.
|
||||
- **Competitive read**: Superficial overlap ("a container per agent task"),
|
||||
but not a direct security-policy competitor. Its useful challenge is the
|
||||
integrated model-selection UX, which bot-bottle intentionally leaves to the
|
||||
selected agent provider.
|
||||
- **Maturity**: Early solo project; HN launch received 1 point.
|
||||
|
||||
### Eve
|
||||
|
||||
- **Source**: https://eve.new/ ;
|
||||
HN launch https://news.ycombinator.com/item?id=47721255
|
||||
- **Isolation/locality**: Managed, hosted Linux sandbox per user/session
|
||||
(claimed 2 vCPU, 4 GB RAM, 10 GB disk), with filesystem, code execution,
|
||||
headless Chromium, and service connectors.
|
||||
- **Agent integration**: End-user OpenClaw-style agent product. An orchestrator
|
||||
routes subtasks to specialist models and can run parallel subagents that
|
||||
coordinate through a shared filesystem. Web UI and iMessage are primary
|
||||
interaction surfaces.
|
||||
- **Network/credentials**: Broad connectors are a product feature; the launch
|
||||
does not document bot-bottle-style default-deny route policy, content DLP,
|
||||
or credentials held outside the sandbox.
|
||||
- **Competitive read**: Adjacent, not direct. Eve sells a managed colleague;
|
||||
bot-bottle lets an operator run existing coding-agent CLIs under local
|
||||
containment. Eve nevertheless demonstrates the appeal of background work,
|
||||
live progress, browser capability, and mobile notification.
|
||||
- **Maturity**: Commercial hosted product; HN launch received 71 points and
|
||||
39 comments.
|
||||
|
||||
### CloudRouter
|
||||
|
||||
- **Source**: https://github.com/manaflow-ai/manaflow/tree/main/packages/cloudrouter ;
|
||||
HN launch https://news.ycombinator.com/item?id=47006393
|
||||
- **Isolation/locality**: Claude Code or Codex runs locally and provisions
|
||||
remote cloud VMs/GPUs for execution. Project files are uploaded to the VM;
|
||||
each machine exposes auth-protected VNC, VS Code, and Jupyter surfaces.
|
||||
- **Agent integration**: A skill plus CLI lets the coding agent itself start,
|
||||
command, inspect, and tear down machines. Browser automation is integrated,
|
||||
including snapshots and screenshots. Parallel disposable compute is the
|
||||
central workflow.
|
||||
- **Network/credentials**: The launch emphasizes remote resource isolation and
|
||||
authenticated UI endpoints, not default-deny guest egress, payload DLP, or
|
||||
proxy-held application credentials.
|
||||
- **Competitive read**: The closest recent workflow competitor. It directly
|
||||
addresses parallel coding agents, environmental conflict, and closing the
|
||||
browser/test loop, but trades local custody for elastic cloud compute.
|
||||
Cloud VMs and GPUs could be a future bot-bottle backend; they do not replace
|
||||
its manifest/policy layer.
|
||||
- **Maturity**: Active open-source monorepo project; HN launch received
|
||||
138 points and 36 comments.
|
||||
|
||||
### Nucleus
|
||||
|
||||
- **Source**: https://github.com/coproduct-opensource/nucleus ;
|
||||
HN launch https://news.ycombinator.com/item?id=46855770
|
||||
- **Isolation/locality**: Firecracker microVM with an enforcing MCP tool proxy.
|
||||
- **Agent integration/config**: Compositional permission envelope for
|
||||
read/write/run actions. The envelope is non-escalating and can tighten or
|
||||
terminate, with scoped approval tokens for gated operations.
|
||||
- **Network/credentials**: Default-deny egress, DNS allowlist, iptables drift
|
||||
detection, time/budget caps, and hash-chained audit logging are claimed.
|
||||
Remote append-only audit storage and attestation were roadmap items at
|
||||
launch.
|
||||
- **Competitive read**: Direct on security architecture, especially
|
||||
non-escalating policy and tamper-evident audit. It is an early execution/tool
|
||||
proxy rather than a provider-neutral, one-command coding-agent product. Its
|
||||
tool-level action envelope is semantically finer than bot-bottle's network
|
||||
boundary; bot-bottle is stronger on turnkey agent/provider integration,
|
||||
credential custody, Git mediation, and long-running operator workflow.
|
||||
- **Maturity**: Early OSS experiment; HN launch received 3 points.
|
||||
|
||||
### yolo-cage
|
||||
|
||||
- **Source**: https://github.com/borenstein/yolo-cage ;
|
||||
HN launch https://news.ycombinator.com/item?id=46706796
|
||||
- **Isolation/locality**: Local sandbox for running multiple coding agents in
|
||||
YOLO mode. The launch discussion describes a VM boundary.
|
||||
- **Agent integration**: Built around the native Claude Code experience and
|
||||
motivated by running many agents in parallel without permission-prompt
|
||||
fatigue.
|
||||
- **Network/Git/credentials**: Strict egress filtering, configurable HTTP
|
||||
middleware, and mediated `git`/`gh` dispatch are the main value. The launch
|
||||
discussion explicitly identifies provider credential handling as unfinished
|
||||
and difficult because Claude state spans multiple host paths.
|
||||
- **Competitive read**: The closest new threat-model competitor. It shares
|
||||
bot-bottle's premise that filesystem isolation alone is insufficient and
|
||||
that Git plus authorized HTTP channels need mediation. bot-bottle currently
|
||||
leads on cross-provider support, proxy-held Claude/Codex/forge credentials,
|
||||
typed per-role manifests, content DLP, and supervision. yolo-cage's simpler
|
||||
pitch and narrower Claude-first setup may be easier to explain.
|
||||
- **Maturity**: Early local tool; HN launch received 60 points and 76 comments.
|
||||
|
||||
### Sandbox Agent SDK
|
||||
|
||||
- **Source**: https://github.com/rivet-dev/sandbox-agent ;
|
||||
HN launch https://news.ycombinator.com/item?id=46795584
|
||||
- **Isolation/locality**: Does not provide the isolation primitive. It runs
|
||||
inside E2B, Daytona, Modal, Cloudflare Containers, Agent Computer, BoxLite,
|
||||
Docker, or another sandbox provider. Embedded mode can also run locally
|
||||
without a sandbox.
|
||||
- **Agent integration**: Provider-neutral Rust server/SDK exposing a common
|
||||
HTTP/SSE/OpenAPI interface across Claude Code, Codex, OpenCode, Cursor, Amp,
|
||||
and Pi, plus a universal event/session schema for external storage and
|
||||
replay. It also exposes filesystem, managed-process, terminal, MCP, skills,
|
||||
custom-tool, and computer-use APIs. TypeScript is the primary SDK surface.
|
||||
- **Network/credentials**: Delegated to the chosen sandbox provider.
|
||||
- **Credential posture**: Its documented convenience command extracts real
|
||||
OpenAI/Anthropic credentials from local agent configuration and passes them
|
||||
as environment variables into the sandbox. That is materially weaker than
|
||||
bot-bottle's host-side credential custody, but it is an integration choice,
|
||||
not a structural limitation: a sandbox provider could put a credential
|
||||
proxy underneath the same SDK.
|
||||
- **Competitive read**: A serious architectural threat despite not supplying
|
||||
isolation. Sandbox Agent is trying to standardize the boundary *above* the
|
||||
sandbox: one client protocol, session model, and UI/control surface across
|
||||
every coding agent and runtime. If that boundary becomes the ecosystem
|
||||
standard, users and application builders may choose a sandbox provider plus
|
||||
Sandbox Agent rather than a vertically integrated launcher. bot-bottle's
|
||||
manifests would then be valuable chiefly as a local policy/backend
|
||||
implementation unless they expose an equally usable control contract.
|
||||
- **Maturity**: Apache 2.0, ~1.5k stars and 426 commits at the 2026-07-27
|
||||
check; HN launch received 41 points.
|
||||
|
||||
#### Why the Sandbox Agent architecture is strategically different
|
||||
|
||||
The manifest and the universal control protocol solve different layers:
|
||||
|
||||
- A bot-bottle manifest is a **trusted launch-time policy composition**. It
|
||||
selects the agent role, isolation backend, image, skills, egress routes,
|
||||
credentials, Git mediation, and supervision policy. Crucially, identity and
|
||||
secret references live on the host side of the trust boundary.
|
||||
- Sandbox Agent is a **runtime control and observation protocol**. A remote
|
||||
client creates sessions, sends messages, handles permissions, configures
|
||||
skills/MCP, manipulates files/processes/desktops, and streams normalized
|
||||
events. It deliberately delegates sandbox lifecycle, Git management,
|
||||
storage, network policy, and credential security to other products.
|
||||
|
||||
That makes it complementary in a component diagram but competitive in product
|
||||
architecture. The layer that becomes the stable integration point tends to own
|
||||
the ecosystem. Three plausible threat paths matter:
|
||||
|
||||
1. **Standard control plane, interchangeable runtimes.** Applications integrate
|
||||
once with Sandbox Agent and treat E2B, Daytona, BoxLite, Docker, or a future
|
||||
local microVM as replaceable compute. A provider that bundles adequate
|
||||
egress and credential custody makes bot-bottle's end-to-end launcher less
|
||||
necessary.
|
||||
2. **Policy grows upward.** Sandbox Agent already configures permissions,
|
||||
skills, MCP, custom tools, filesystem/process access, and computer use. If
|
||||
it adds a declarative, host-verifiable policy document, the overlap with
|
||||
agent/bottle manifests becomes substantial even if enforcement remains
|
||||
delegated.
|
||||
3. **UI and session ownership.** Its universal transcript schema, Inspector,
|
||||
React components, event replay, and remote terminal/computer APIs can become
|
||||
the natural basis for desktop, web, and mobile agent managers. bot-bottle's
|
||||
security layer could remain stronger while losing the operator surface and
|
||||
distribution channel.
|
||||
|
||||
The counter-position is not to claim that manifests and an API are mutually
|
||||
exclusive. The defensible split is:
|
||||
|
||||
- bot-bottle owns the trusted policy and enforcement plane outside the agent;
|
||||
- a provider-neutral protocol owns agent process control and normalized
|
||||
events; and
|
||||
- the operator UI consumes both.
|
||||
|
||||
This suggests an explicit compatibility decision rather than parallel,
|
||||
accidental protocol design: evaluate running Sandbox Agent inside a bottle and
|
||||
exposing it only through the authenticated bot-bottle control plane. If its
|
||||
schema is suitable, adopting it could turn a threat into an integration while
|
||||
keeping manifests as the higher-trust policy source. If it is unsuitable,
|
||||
bot-bottle should still publish a stable provider-neutral session/event API so
|
||||
frontends do not depend on Claude/Codex/Pi-specific process behavior.
|
||||
|
||||
## Comparison table
|
||||
|
||||
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
|
||||
@@ -616,6 +817,70 @@ would be a *backend* bot-bottle could call, not a competitor to its
|
||||
manifest layer. endo-familiar is in a different paradigm entirely:
|
||||
capability passing rather than kernel boundaries.
|
||||
|
||||
**Recent entrants change two parts of this read.** yolo-cage is closer to the
|
||||
actual threat model than agent-safehouse or litterbox: it combines a VM-style
|
||||
boundary with mediated Git and filtered HTTP specifically for parallel coding
|
||||
agents. Sandbox Agent SDK is the more important strategic entrant even though
|
||||
it supplies no isolation. It can become the standard agent-control layer above
|
||||
all of these runtimes, including a future bot-bottle backend. CloudRouter is
|
||||
the clearest workflow challenge because its browser/desktop/GPU loop makes
|
||||
parallel agents visibly more capable, not merely safer.
|
||||
|
||||
## Gap evaluation after the 2026-07-27 entrant scan
|
||||
|
||||
### Material gaps
|
||||
|
||||
1. **A stable provider-neutral control and event protocol.** This is the
|
||||
largest newly visible gap. bot-bottle normalizes launch/provisioning across
|
||||
providers, but an external UI or orchestrator still lacks one documented
|
||||
contract for creating a Claude/Codex/Pi session, sending input, handling
|
||||
permission/supervision events, streaming normalized output, reconnecting,
|
||||
and replaying history. Sandbox Agent SDK addresses exactly this layer and
|
||||
is already portable across many sandbox providers.
|
||||
2. **Browser/preview closure.** CloudRouter and Eve make a browser or desktop
|
||||
part of the standard agent environment and expose screenshots/live viewing
|
||||
to the operator. bot-bottle can run dev servers and supports nested
|
||||
containers, but it does not present a first-class browser/computer-use
|
||||
primitive or an auth-protected preview surface. For coding agents expected
|
||||
to verify UI work, this is a real product gap.
|
||||
3. **Unified parallel-session operator UX.** Named persistent bottles and
|
||||
supervision provide the substrate, but the recent products make task
|
||||
switching, live progress, notifications, terminal attach, diffs, and
|
||||
session history the product. Security depth will not compensate for a
|
||||
visibly rougher daily loop.
|
||||
4. **Normalized transcript persistence and replay.** bot-bottle preserves
|
||||
provider-specific state for resume; it does not expose a provider-neutral
|
||||
event record suitable for audit, replay, analytics, or a web/mobile client.
|
||||
This is both a UX gap and an audit gap.
|
||||
|
||||
### Important, but not necessarily bot-bottle features
|
||||
|
||||
- **Cloud VM/GPU provisioning.** Valuable for elastic workloads and could be a
|
||||
backend, but it conflicts with the local-custody default and should not
|
||||
displace core policy work.
|
||||
- **Automatic model routing.** Black LLAB and Eve sell task-to-model routing.
|
||||
bot-bottle's provider-template boundary can host that choice without making
|
||||
it part of the trusted sandbox policy.
|
||||
- **A thousand SaaS connectors.** This broadens capability and blast radius.
|
||||
The bot-bottle-native answer should remain explicit, scoped forge/egress
|
||||
associations rather than connector count as a goal.
|
||||
- **SDK-driven sandbox lifecycle as the primary configuration model.** Useful
|
||||
for platform builders, but not a replacement for reviewable, host-owned
|
||||
manifests. A control API and a declarative policy source are compatible;
|
||||
neither should silently become the other.
|
||||
|
||||
### Areas where bot-bottle remains ahead
|
||||
|
||||
- real provider and forge credentials remain outside the agent process rather
|
||||
than being extracted into its environment;
|
||||
- authorized HTTP payloads are scanned, not merely destination-filtered;
|
||||
- Git writes traverse a distinct gate with secret scanning and host-held
|
||||
upstream credentials;
|
||||
- role policy is host-owned, composable, and separate from untrusted repo
|
||||
content; and
|
||||
- local Firecracker/Apple Container execution preserves operator custody
|
||||
without requiring a hosted sandbox platform.
|
||||
|
||||
## Borrowable ideas
|
||||
|
||||
### Already shipped or otherwise addressed
|
||||
@@ -642,6 +907,19 @@ capability passing rather than kernel boundaries.
|
||||
|
||||
### Still worth considering
|
||||
|
||||
- **Sandbox Agent compatibility or an equivalent stable protocol (highest
|
||||
priority):** spike running its server inside a bottle behind bot-bottle's
|
||||
authenticated control plane. Compare its session/event schema, permission
|
||||
model, restore semantics, and provider coverage with current provider
|
||||
adapters. Adopt compatibility if it preserves the host-owned trust boundary;
|
||||
otherwise specify bot-bottle's own stable API before building another UI.
|
||||
- **First-class browser/preview loop** (from CloudRouter and Eve): give a
|
||||
bottle an optional browser/computer-use capability plus an operator-visible,
|
||||
authenticated preview/screenshot surface. Treat its network access as part
|
||||
of the bottle policy, not an implicit bypass.
|
||||
- **Provider-neutral transcript/event persistence** (from Sandbox Agent SDK):
|
||||
retain enough normalized structure for replay and audit while preserving the
|
||||
provider-native state needed for exact resume.
|
||||
- **Live network activity in the supervisor TUI** (from Docker sbx): show
|
||||
allowed and blocked connections and let the operator propose policy changes
|
||||
from the existing supervision surface.
|
||||
@@ -652,10 +930,11 @@ capability passing rather than kernel boundaries.
|
||||
closer review. This needs a carefully specified trust model before it can be
|
||||
more than a heuristic.
|
||||
|
||||
Not worth borrowing: the SDK-first programmatic API style of boxlite /
|
||||
microsandbox (cuts against the declarative-manifest stance), and the
|
||||
hosted-SaaS dashboard model of tilde.run (cuts against the
|
||||
"infrastructure I control" goal).
|
||||
Not worth borrowing: SDK-first *policy configuration* as used by boxlite /
|
||||
microsandbox (cuts against the reviewable declarative-manifest stance), and
|
||||
the hosted-SaaS custody model of tilde.run (cuts against the "infrastructure I
|
||||
control" goal). A provider-neutral runtime-control API is a separate concern
|
||||
and is worth borrowing.
|
||||
|
||||
## Publishing and positioning verdict
|
||||
|
||||
@@ -679,9 +958,15 @@ bot-bottle remains unusual in combining:
|
||||
The practical wedge is “as easy as native yolo, with declarative role policy
|
||||
and self-hosted custody,” including scoped access to private LAN/Tailnet
|
||||
services that cloud-first runtimes cannot provide without additional network
|
||||
plumbing. The main competitive risks are a local wrapper such as claudebox or
|
||||
Docker sbx growing a role-manifest layer, and GUI products such as SuperHQ
|
||||
adding equivalent policy and audit depth.
|
||||
plumbing. The main competitive risks are now:
|
||||
|
||||
- a local wrapper such as yolo-cage, claudebox, or Docker sbx growing a
|
||||
role-manifest and credential-custody layer;
|
||||
- Sandbox Agent SDK becoming the standard control/session boundary and making
|
||||
the runtime beneath it interchangeable; and
|
||||
- GUI products such as SuperHQ or CloudRouter adding equivalent policy and
|
||||
audit depth before bot-bottle closes the browser/preview and
|
||||
parallel-session UX gaps.
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Can bot-bottle grow a built-in supervisor — TUI inventory plus PR-feedback rou
|
||||
|
||||
## Context
|
||||
|
||||
bot-bottle today is a fleet *executor*: `./cli.py start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
|
||||
bot-bottle today is a fleet *executor*: `bot-bottle start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
|
||||
|
||||
A separate survey of the broader ecosystem ([agent control dashboards research, mid-2026](https://gitea.dideric.is/didericis/consilium-research/src/branch/main/developer-workflow/agent-control-dashboards-2026-05-24.md)) sorts dashboards into five tiers (session managers, parallel runners, Kanban boards, mission-control SPAs, observability backends). The earlier first-pass conclusion was that a full SPA tier conflicts with bot-bottle's isolation model. This doc reconsiders the smaller question: a TUI supervisor in the existing Python CLI.
|
||||
|
||||
@@ -25,13 +25,13 @@ A supervisor doesn't have to be heavy. A TUI built into the existing Python CLI,
|
||||
|
||||
Three layers, each independently useful, in order of ambition:
|
||||
|
||||
### 1. `./cli.py status` — read-only inventory
|
||||
### 1. `bot-bottle status` — read-only inventory
|
||||
|
||||
Reads `docker ps` filtered by a bottle label and tails each bottle's session log. Reports per bottle: name, agent, uptime, last-activity timestamp, token spend if available, associated PR/branch if recorded.
|
||||
|
||||
No new daemons. No new ports. No new credentials. ~100 lines.
|
||||
|
||||
### 2. `./cli.py watch` — TUI over the same data
|
||||
### 2. `bot-bottle watch` — TUI over the same data
|
||||
|
||||
Same data as `status`, rendered with auto-refresh and keyboard shortcuts that shell out to the existing `cli.py attach / stop / start` commands.
|
||||
|
||||
@@ -39,7 +39,7 @@ Library choice: prefer the stdlib `curses` module to stay stdlib-first; fall bac
|
||||
|
||||
This is the Claude Squad / tmux-agent-status pattern, applied to bottles instead of tmux sessions. The whole category exists *because* a TUI is the lightweight shape that doesn't require what the SPA tier requires.
|
||||
|
||||
### 3. `./cli.py supervise` — PR feedback router
|
||||
### 3. `bot-bottle supervise` — PR feedback router
|
||||
|
||||
The optional, more ambitious layer. The bottle manifest gains an optional field:
|
||||
|
||||
@@ -49,7 +49,7 @@ pr_watch:
|
||||
branch: agent/task-42
|
||||
```
|
||||
|
||||
`./cli.py supervise` polls the named upstream for new review comments and CI failures on `branch`. When one fires, it surfaces as a desktop notification or a flash in the TUI. The human decides what to do with the feedback — there is no autonomous loop that feeds the comment back into a bottle's next prompt (see "Where to be conservative" for why).
|
||||
`bot-bottle supervise` polls the named upstream for new review comments and CI failures on `branch`. When one fires, it surfaces as a desktop notification or a flash in the TUI. The human decides what to do with the feedback — there is no autonomous loop that feeds the comment back into a bottle's next prompt (see "Where to be conservative" for why).
|
||||
|
||||
The polling token is a **host** token (the same `GH_PAT` / Gitea token the host already keeps in shell env), not a bottle credential. The supervisor never holds bottle secrets.
|
||||
|
||||
@@ -62,7 +62,7 @@ The load-bearing question is whether the supervisor introduces the privileged-ch
|
||||
| Reaching into running bottles | Supervisor reads `docker ps` and host-side log files. The host already sees both — Docker is the trust boundary, the supervisor is on the host side of it. |
|
||||
| Holding bottle credentials | The polling token is a host token. The supervisor never receives `bottle.cred_proxy.routes` entries; it has no path to them. |
|
||||
| Bridging between bottles | The supervisor does not relay state from bottle A to bottle B. It relays *upstream PR state* to a bottle's next prompt — and only if the manifest opts in. |
|
||||
| New attack surface | All "control" actions go through `./cli.py start <agent>`, which already enforces the manifest. The supervisor is an automated caller of the existing CLI, not a parallel control plane. |
|
||||
| New attack surface | All "control" actions go through `bot-bottle start <agent>`, which already enforces the manifest. The supervisor is an automated caller of the existing CLI, not a parallel control plane. |
|
||||
|
||||
The boundary stays at the bottle wall. The supervisor looks outward at git/PR state and downward at Docker; it does not look *inward* through pipelock.
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ What's the cheapest path to that, and where does it bottom out?
|
||||
|
||||
## What "interact" means
|
||||
|
||||
Today the flow is bimodal. `./cli.py start <agent>` brings the
|
||||
Today the flow is bimodal. `bot-bottle start <agent>` brings the
|
||||
bottle up and immediately drops you into an interactive
|
||||
`docker exec -it bot-bottle-<slug> claude ...` — claude-code
|
||||
owns the whole terminal until you Ctrl-D out, at which point the
|
||||
bottle tears down. The dashboard (`./cli.py dashboard`) is a
|
||||
bottle tears down. The dashboard (`bot-bottle dashboard`) is a
|
||||
*separate* invocation that watches across bottles but never
|
||||
exposes the claude TUI itself.
|
||||
|
||||
@@ -107,7 +107,7 @@ What's not good:
|
||||
|
||||
This is the v1 the project's existing code-shape strongly
|
||||
prefers. It clears the bar of "let me talk to claude-code
|
||||
without quitting `./cli.py dashboard`."
|
||||
without quitting `bot-bottle dashboard`."
|
||||
|
||||
## Option 2: Embedded emulator
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ dangerous changes before they left*.
|
||||
(`supervise_gitleaks_allow`, [`git_gate_render.py`](../../bot_bottle/git_gate_render.py)).
|
||||
Extend the same flow to **high-risk file classes**: any commit touching
|
||||
CI/build/deploy scripts, auth/crypto code, egress config, or
|
||||
adding/changing dependencies → route to `./cli.py supervise`. This is
|
||||
adding/changing dependencies → route to `bot-bottle supervise`. This is
|
||||
attribution/policy, not detection, and it's the strongest thing here —
|
||||
a human on exactly the temporal-escape surfaces.
|
||||
4. **LLM semantic diff-review — the behavioral backstop.** The only
|
||||
@@ -146,7 +146,7 @@ preserves the bottom-up distribution funnel.
|
||||
**governed code-egress review**, not "we resell inference" (the
|
||||
monetization notes explicitly warn against reselling compute).
|
||||
- **The web-console supervise/review flow — the strongest anchor.** Turn
|
||||
the CLI `./cli.py supervise` approval into a real review surface:
|
||||
the CLI `bot-bottle supervise` approval into a real review surface:
|
||||
rendered diff + finding context, approve/reject, **who-approved audit
|
||||
trail, RBAC on approvers, mobile/phone-control** (ties to the
|
||||
dashboard/vault north star). This is "central enforcement +
|
||||
|
||||
@@ -100,7 +100,7 @@ resolver globs each directory.
|
||||
becomes file ops (mkdir, mv, rm) instead of editing one file.
|
||||
Power users prefer that; new users may not.
|
||||
- Discovery requires `ls`, not "grep one file." Tooling helps
|
||||
(e.g. `./cli.py list`) but the manifest is no longer a single
|
||||
(e.g. `bot-bottle list`) but the manifest is no longer a single
|
||||
artifact to email or ship.
|
||||
- Atomicity: swapping a bottle name across agents touches
|
||||
multiple files. Git handles this fine; a one-shot text editor
|
||||
@@ -364,7 +364,7 @@ wins; none of the body-prose or dependency story.
|
||||
warns / ignores / breaks. If it warns, we'd want a different
|
||||
field name (e.g. `bot-bottle-bottle`) or a namespaced block.
|
||||
- **Migration story.** Is the project willing to ship a one-shot
|
||||
`./cli.py migrate-manifest` command that does the JSON → MD
|
||||
`bot-bottle migrate-manifest` command that does the JSON → MD
|
||||
conversion? Or do users just rewrite by hand from the new docs?
|
||||
- **Bottle file body content.** If most bottle .md files have an
|
||||
empty body, is the MD-with-frontmatter format still warranted?
|
||||
|
||||
@@ -34,7 +34,7 @@ on top of working onboarding.
|
||||
|
||||
A first-time user today goes through five steps: install Docker,
|
||||
install `uv`, set `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, write
|
||||
`bot-bottle.json`, run `./cli.py start`. One of those is
|
||||
`bot-bottle.json`, run `bot-bottle start`. One of those is
|
||||
"author a JSON manifest." Polished tools in this category let
|
||||
users skip that step on day one. The fix is an `init` subcommand
|
||||
that drops a working `bot-bottle.json` with a default `coder`
|
||||
|
||||
@@ -131,7 +131,7 @@ The minimum-viable workflow, no bot-bottle code changes:
|
||||
3. SSH in.
|
||||
4. `git clone` bot-bottle on the VM, drop a manifest in place,
|
||||
inject `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` via the provider's secrets path.
|
||||
5. `./cli.py start <agent>` — the existing launcher handles the rest.
|
||||
5. `bot-bottle start <agent>` — the existing launcher handles the rest.
|
||||
6. On exit: destroy the VM. No host artifacts persist.
|
||||
|
||||
For the "VPN pivot" failure mode, see
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
# Sandbox Agent SDK and bot-bottle: protocol versus product
|
||||
|
||||
This note asks whether [Sandbox Agent SDK](https://github.com/rivet-dev/sandbox-agent)
|
||||
and bot-bottle compete for the same architectural layer, whether bot-bottle
|
||||
can productize the turnkey ecosystem/DX layer above it, and how far the
|
||||
Docker/OCI analogy actually holds.
|
||||
|
||||
Research conducted 2026-07-27. Sandbox Agent SDK was at the `0.4.x` line,
|
||||
Apache 2.0, and documented support for Claude Code, Codex, OpenCode, Cursor,
|
||||
Amp, and Pi at the time of review.
|
||||
|
||||
## Summary
|
||||
|
||||
**The projects are complementary at the component boundary and competitive at
|
||||
the product boundary.** Sandbox Agent SDK normalizes how software controls a
|
||||
coding-agent process inside an arbitrary sandbox. bot-bottle decides what
|
||||
sandbox to create, what trusted role and policy it receives, how credentials
|
||||
and Git access cross the boundary, how traffic is constrained, and how an
|
||||
operator launches and supervises the result.
|
||||
|
||||
The Docker analogy is useful with one correction:
|
||||
|
||||
- Sandbox Agent SDK is not equivalent to Linux container APIs or OCI itself.
|
||||
It is closer to a **containerd shim plus a portable exec/session API for
|
||||
coding agents**. It adapts incompatible agent processes to one HTTP/SSE
|
||||
contract.
|
||||
- A future independent agent-session specification would be the closer OCI
|
||||
analogue.
|
||||
- bot-bottle can credibly occupy the **Docker Engine / Compose / Desktop**
|
||||
layer: packaging, policy composition, lifecycle, networking, credentials,
|
||||
storage, operator UX, and a one-command experience above interchangeable
|
||||
agent adapters and isolation runtimes.
|
||||
|
||||
That is a viable position, but “turnkey wrapper” undersells it. A thin wrapper
|
||||
is replaceable. The valuable product is a **turnkey, policy-first coding-agent
|
||||
runtime** whose manifest compiles trusted operator intent into multiple
|
||||
enforcement planes. Sandbox Agent SDK may be one internal process-control
|
||||
component of that product.
|
||||
|
||||
The recommended direction is:
|
||||
|
||||
1. Keep the bot-bottle manifest as the host-owned source of trusted policy.
|
||||
2. Spike Sandbox Agent SDK as the in-bottle provider/session adapter.
|
||||
3. Expose a stable, provider-neutral bot-bottle control API, compatible with
|
||||
Sandbox Agent where practical.
|
||||
4. Keep security decisions and authoritative audit outside the sandbox.
|
||||
5. Build the ecosystem around policy packs, agent images, skills, backends,
|
||||
operator UI, and trusted integrations—not around a proprietary transcript
|
||||
protocol.
|
||||
|
||||
## What each project is today
|
||||
|
||||
### Sandbox Agent SDK
|
||||
|
||||
Sandbox Agent is a Rust server that runs alongside the coding agent. A client
|
||||
connects over HTTP, streams events over SSE, and uses one API across agent
|
||||
implementations. Its documented surface includes:
|
||||
|
||||
- creating and restoring agent sessions;
|
||||
- sending messages and streaming normalized events;
|
||||
- handling permissions;
|
||||
- configuring MCP servers, skills, and custom tools;
|
||||
- filesystem and managed-process APIs;
|
||||
- interactive terminal access;
|
||||
- computer-use/desktop operations;
|
||||
- a universal session/transcript schema;
|
||||
- an Inspector UI, React components, CLI, TypeScript SDK, and OpenAPI spec.
|
||||
|
||||
It can run in embedded mode or inside E2B, Daytona, Modal, Cloudflare
|
||||
Containers, Agent Computer, BoxLite, Docker, and other environments. It
|
||||
explicitly leaves these concerns to the caller or sandbox provider:
|
||||
|
||||
- sandbox creation and lifecycle;
|
||||
- Git repository management;
|
||||
- durable session storage;
|
||||
- network policy;
|
||||
- isolation strength; and
|
||||
- secure credential delivery.
|
||||
|
||||
Its documented credential convenience path extracts real provider credentials
|
||||
from local agent configuration and passes them into the sandbox environment.
|
||||
That is convenient but is not an acceptable security boundary for bot-bottle.
|
||||
|
||||
Sources:
|
||||
|
||||
- [Sandbox Agent repository and architecture](https://github.com/rivet-dev/sandbox-agent)
|
||||
- [Sandbox Agent documentation](https://sandboxagent.dev/docs)
|
||||
- [HTTP API](https://sandboxagent.dev/docs/api-reference)
|
||||
- [Universal session/transcript schema](https://sandboxagent.dev/docs/session-transcript-schema)
|
||||
|
||||
### bot-bottle
|
||||
|
||||
bot-bottle is a host-side launch, policy, and enforcement system for existing
|
||||
coding-agent CLIs. Its current architecture includes:
|
||||
|
||||
- agent and bottle manifests with composition via `extends:`;
|
||||
- a host-only trust boundary for roles, identity, and secret references;
|
||||
- provider templates and plugins for Claude Code, Codex, Pi, and custom
|
||||
providers;
|
||||
- Firecracker on KVM Linux and Apple Container on macOS, with Docker fallback;
|
||||
- image construction and provider-specific provisioning;
|
||||
- default-deny inspected egress with path/method/header policy;
|
||||
- payload DLP on authorized channels;
|
||||
- real credentials held outside the agent and injected by the gateway;
|
||||
- Git mediation, upstream credential custody, and gitleaks scanning;
|
||||
- a per-host authenticated orchestrator and shared gateway;
|
||||
- named bottle lifecycle, resume, supervision, and audit state; and
|
||||
- a CLI/TUI intended to make full-permission agents operationally tolerable.
|
||||
|
||||
The provider layer currently normalizes launch-time concerns—command, image,
|
||||
prompt delivery, files, skills, environment, verification, and provider-owned
|
||||
egress routes. It does **not** yet expose a stable provider-neutral runtime
|
||||
contract for sessions, messages, transcripts, terminals, or normalized events.
|
||||
That is the gap Sandbox Agent directly illuminates.
|
||||
|
||||
Sources in this repository:
|
||||
|
||||
- [`README.md`](../../README.md)
|
||||
- [`0070-per-host-orchestrator.md`](../prds/0070-per-host-orchestrator.md)
|
||||
- [`0026-agent-provider-templates.md`](../prds/0026-agent-provider-templates.md)
|
||||
- [`0053-user-provider-plugins.md`](../prds/0053-user-provider-plugins.md)
|
||||
- [`agent_provider.py`](../../bot_bottle/agent_provider.py)
|
||||
|
||||
## The layer model
|
||||
|
||||
The cleanest architecture has four layers:
|
||||
|
||||
| Layer | Responsibility | Likely owner |
|
||||
|---|---|---|
|
||||
| Operator product | Install, select a role, launch, observe, intervene, resume, review changes | bot-bottle |
|
||||
| Trusted policy and lifecycle | Compose manifest, choose backend/image, hold credentials, enforce egress/Git, persist authoritative audit | bot-bottle |
|
||||
| Agent control protocol | Start provider process, create session, send input, stream normalized events, terminal/computer operations | Sandbox Agent or a compatible protocol |
|
||||
| Isolation primitive | VM/container/process boundary, filesystem, CPU/memory, networking substrate | Firecracker, Apple Container, Docker, E2B, Daytona, BoxLite, etc. |
|
||||
|
||||
The important boundary is between trusted policy/lifecycle and agent control.
|
||||
The agent-control daemon runs in the environment being treated as untrusted.
|
||||
It can report what the agent says happened, but it cannot authoritatively prove
|
||||
that policy was enforced. Egress decisions, credential custody, Git scanning,
|
||||
bottle identity, and security audit must remain outside it.
|
||||
|
||||
### Proposed composition
|
||||
|
||||
```text
|
||||
operator UI / CLI / API
|
||||
|
|
||||
v
|
||||
bot-bottle orchestrator (trusted)
|
||||
- resolves manifest
|
||||
- owns bottle identity and lifecycle
|
||||
- stores authoritative audit
|
||||
- authenticates clients
|
||||
|
|
||||
+--------------------------+
|
||||
| |
|
||||
v v
|
||||
isolation backend shared gateway (trusted)
|
||||
Firecracker / Apple / Docker - egress policy + DLP
|
||||
| - credential injection
|
||||
| - Git mediation
|
||||
v
|
||||
bottle / guest (untrusted)
|
||||
- Sandbox Agent server
|
||||
- Claude Code / Codex / Pi subprocess
|
||||
- workspace, skills, MCP configuration
|
||||
```
|
||||
|
||||
The bot-bottle manifest would compile into both sides:
|
||||
|
||||
- **outside the bottle:** backend, network, egress, credentials, Git,
|
||||
supervision, identity, and authoritative lifecycle;
|
||||
- **inside the bottle:** selected provider, prompt, skills, MCP configuration,
|
||||
startup arguments, and non-secret session metadata.
|
||||
|
||||
Sandbox Agent should never receive real secrets merely because its API offers
|
||||
a credential extraction helper. Provider and forge requests should continue
|
||||
to use bot-bottle's placeholder/proxy pattern.
|
||||
|
||||
## How accurate is the Docker/OCI analogy?
|
||||
|
||||
### The useful part
|
||||
|
||||
The container ecosystem separates low-level execution from a product that
|
||||
ordinary developers operate. OCI defines interoperable image, runtime, and
|
||||
distribution specifications. Docker Engine adds a daemon, API, CLI, object
|
||||
model, images, networks, volumes, and lifecycle; Docker Desktop and related
|
||||
products add installation, updates, UI, integrations, policy, and team
|
||||
workflows.
|
||||
|
||||
The same separation can exist for coding agents:
|
||||
|
||||
| Container ecosystem | Agent-sandbox ecosystem |
|
||||
|---|---|
|
||||
| OCI/runtime contract | A future open agent session/event contract |
|
||||
| `runc` / runtime adapter | Claude/Codex/Pi adapter |
|
||||
| containerd shim and task/exec API | Sandbox Agent server and HTTP/SSE session API |
|
||||
| containerd / CRI-style lifecycle | Sandbox-provider lifecycle APIs |
|
||||
| Docker Engine / Compose | bot-bottle orchestrator + manifests + backends + gateway |
|
||||
| Docker Desktop / Hub ecosystem | bot-bottle desktop/mobile UX, policy packs, agent images, skills, trusted integrations |
|
||||
|
||||
Sandbox Agent makes coding-agent processes portable in roughly the way a shim
|
||||
makes runtimes consumable through a common lifecycle interface. bot-bottle can
|
||||
make the entire safe-agent system usable without asking the operator to
|
||||
assemble that plumbing.
|
||||
|
||||
Official container references:
|
||||
|
||||
- [Open Container Initiative](https://opencontainers.org/)
|
||||
- [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec)
|
||||
- [Docker Engine architecture](https://docs.docker.com/engine/)
|
||||
- [Docker alternative runtimes and containerd shims](https://docs.docker.com/engine/daemon/alternative-runtimes/)
|
||||
|
||||
### Where the analogy breaks
|
||||
|
||||
1. **Sandbox Agent is an implementation, not an independent standard.**
|
||||
Its OpenAPI document is public, but the project currently owns the server,
|
||||
adapters, schema, and evolution. OCI is an independently governed set of
|
||||
specifications with multiple implementations.
|
||||
2. **It sits above, not below, the isolation boundary.** Linux namespaces,
|
||||
cgroups, VMs, and OCI runtimes create the boundary. Sandbox Agent controls a
|
||||
process after some other system has created that boundary.
|
||||
3. **It reaches into product territory.** Inspector, React components,
|
||||
computer-use APIs, skills/MCP configuration, transcripts, and restoration
|
||||
are not merely low-level primitives. Sandbox Agent can continue growing
|
||||
upward into the same UI and orchestration space bot-bottle might occupy.
|
||||
4. **Coding agents are semantically uneven.** Normalizing a container
|
||||
lifecycle is easier than claiming full behavioral parity across Claude
|
||||
Code, Codex, Cursor, Amp, OpenCode, and Pi. A universal schema can become a
|
||||
lowest common denominator or accumulate provider-specific escape hatches.
|
||||
5. **The security contract is not standardized.** An agent-session API says
|
||||
little about whether credentials are visible, egress is controlled, Git is
|
||||
mediated, or audit is trustworthy. Those are core bot-bottle concerns.
|
||||
|
||||
The positioning should therefore say “Docker-like product layer above an open
|
||||
agent-control protocol,” not “Sandbox Agent is OCI” or “bot-bottle implements
|
||||
OCI for agents.”
|
||||
|
||||
## Can bot-bottle be the turnkey product layer?
|
||||
|
||||
Yes, if it owns substantially more than launch syntax.
|
||||
|
||||
The turnkey promise is:
|
||||
|
||||
> Choose a trusted role, point it at a project, and run any supported coding
|
||||
> agent with full permissions. bot-bottle builds the environment, isolates it,
|
||||
> supplies only the capabilities it needs, keeps credentials outside, mediates
|
||||
> external writes, and gives the operator one place to watch and intervene.
|
||||
|
||||
That product has several defensible jobs:
|
||||
|
||||
### 1. Packaging and reproducibility
|
||||
|
||||
- provider and toolchain images;
|
||||
- pinned, verified build inputs;
|
||||
- skills and MCP configuration;
|
||||
- role/bottle composition;
|
||||
- cached startup and portable environment definitions; and
|
||||
- compatibility testing across agents and backends.
|
||||
|
||||
### 2. Trusted policy compilation
|
||||
|
||||
The manifest is valuable because one reviewable document compiles into:
|
||||
|
||||
- an isolation plan;
|
||||
- gateway routes and DLP policy;
|
||||
- credential slots;
|
||||
- Git-gate repositories and identities;
|
||||
- provider configuration;
|
||||
- supervision behavior; and
|
||||
- operator-facing preflight.
|
||||
|
||||
Sandbox Agent's runtime configuration does not replace this. The policy must
|
||||
be resolved before an untrusted guest or agent-control daemon exists.
|
||||
|
||||
### 3. Security enforcement
|
||||
|
||||
- dedicated-kernel isolation where available;
|
||||
- no direct guest route to the internet;
|
||||
- credentials injected outside the agent;
|
||||
- content inspection on allowed destinations;
|
||||
- Git secrets scanning and upstream-key custody;
|
||||
- fail-closed policy resolution; and
|
||||
- authoritative host-side audit.
|
||||
|
||||
This is the strongest current differentiation from a generic
|
||||
“Sandbox Agent + Docker/E2B” assembly.
|
||||
|
||||
### 4. Lifecycle and operations
|
||||
|
||||
- install and host preflight;
|
||||
- image build/update;
|
||||
- start, stop, resume, cleanup, and migration;
|
||||
- concurrent named agents;
|
||||
- state recovery after crashes;
|
||||
- live supervision and policy remediation; and
|
||||
- backend selection without changing the role definition.
|
||||
|
||||
### 5. Ecosystem and DX
|
||||
|
||||
A product layer can support:
|
||||
|
||||
- curated provider images;
|
||||
- signed policy/bottle packs;
|
||||
- reusable role templates;
|
||||
- skills and MCP bundles;
|
||||
- backend plugins;
|
||||
- an authenticated desktop/web/mobile operator client;
|
||||
- browser/preview integration;
|
||||
- normalized transcripts and change review; and
|
||||
- team policy distribution and compliance exports.
|
||||
|
||||
The analogy to Docker is strongest here: users adopt the coherent workflow and
|
||||
ecosystem, not because the low-level process API is proprietary.
|
||||
|
||||
## Business and product positioning
|
||||
|
||||
“Turnkey wrapper” is understandable internally but weak externally. It implies
|
||||
that the hard work lives underneath and that another wrapper can replace it.
|
||||
Prefer one of:
|
||||
|
||||
- **The policy-first runtime for coding agents**
|
||||
- **Run any coding agent with full permissions, without giving it your host or
|
||||
credentials**
|
||||
- **A turnkey local control plane for isolated coding agents**
|
||||
- **Docker-like packaging and operations for coding agents, with the security
|
||||
boundary outside the agent**
|
||||
|
||||
The open/product split could resemble the container ecosystem:
|
||||
|
||||
### Open foundation
|
||||
|
||||
- manifest schema and composition;
|
||||
- local CLI and core orchestrator;
|
||||
- provider adapters;
|
||||
- Firecracker/Apple Container/Docker backends;
|
||||
- gateway policy format and enforcement;
|
||||
- Sandbox Agent compatibility;
|
||||
- local audit and supervision; and
|
||||
- conformance tests for providers/backends/policy.
|
||||
|
||||
### Productizable ecosystem/DX
|
||||
|
||||
- polished desktop and mobile clients;
|
||||
- fleet/remote-host management;
|
||||
- signed and curated role/image/policy registry;
|
||||
- team policy distribution and administrative controls;
|
||||
- durable searchable transcripts and audit exports;
|
||||
- SSO, RBAC, retention, and tamper-evident audit;
|
||||
- managed update/compatibility channels;
|
||||
- remote browser/preview relay;
|
||||
- enterprise support; and
|
||||
- optional managed build/cache infrastructure.
|
||||
|
||||
OCI itself is not the thing Docker sells. Interoperability expands the market;
|
||||
the product captures value through reliable packaging, workflow, distribution,
|
||||
management, and trust. bot-bottle should follow that logic rather than trying
|
||||
to make its session protocol the moat.
|
||||
|
||||
## Strategic threat from Sandbox Agent
|
||||
|
||||
Sandbox Agent is a real threat for three reasons:
|
||||
|
||||
1. **It can become the integration default.** A frontend or agent platform can
|
||||
integrate one API and choose among many agents and sandbox vendors.
|
||||
2. **It can own session data and UI.** The universal event schema, Inspector,
|
||||
React components, restoration, terminal, and computer-use APIs give it a
|
||||
natural path toward the operator surface.
|
||||
3. **Sandbox providers can move upward.** If E2B, Daytona, BoxLite, or another
|
||||
runtime combines Sandbox Agent with adequate network policy and credential
|
||||
custody, it can offer much of the turnkey stack.
|
||||
|
||||
The threat is not that its manifest syntax is better. It currently has no
|
||||
equivalent trusted policy composition. The threat is that **the ecosystem may
|
||||
standardize around its API before bot-bottle has a stable external control
|
||||
surface**. In that world bot-bottle is evaluated as one sandbox provider,
|
||||
while the SDK and its consumers own the user relationship.
|
||||
|
||||
## Why bot-bottle can still win its layer
|
||||
|
||||
Sandbox Agent's scope exclusions align with bot-bottle's deepest work:
|
||||
|
||||
- it does not choose or operate the sandbox provider;
|
||||
- it does not mediate Git;
|
||||
- it does not own network policy;
|
||||
- it does not securely deliver credentials;
|
||||
- it does not durably store sessions; and
|
||||
- it cannot make guest-generated telemetry authoritative.
|
||||
|
||||
Those are not incidental features. Together they define the trusted system
|
||||
around an untrusted coding agent. bot-bottle also has a narrower and coherent
|
||||
initial customer: a developer or small operator who wants existing agent CLIs
|
||||
to run locally with broad permissions and bounded consequences.
|
||||
|
||||
The durable advantage is therefore:
|
||||
|
||||
> Sandbox Agent makes agents controllable. bot-bottle makes them safe and
|
||||
> operable.
|
||||
|
||||
That sentence remains true only if bot-bottle closes its operator-DX gaps.
|
||||
Security without a browser/preview loop, stable API, normalized session view,
|
||||
and good parallel-task UX risks becoming an invisible backend feature.
|
||||
|
||||
## Integration options
|
||||
|
||||
### Option A — Embed Sandbox Agent inside each bottle
|
||||
|
||||
bot-bottle launches Sandbox Agent as the provider process supervisor and
|
||||
connects it to the host orchestrator through a bottle-scoped authenticated
|
||||
channel.
|
||||
|
||||
**Benefits**
|
||||
|
||||
- immediate provider-neutral session API;
|
||||
- more supported agents;
|
||||
- normalized streaming and transcripts;
|
||||
- terminal, filesystem, process, and computer-use primitives;
|
||||
- Inspector/React ecosystem; and
|
||||
- less provider-specific reverse engineering in bot-bottle.
|
||||
|
||||
**Risks**
|
||||
|
||||
- `0.x` API/schema churn;
|
||||
- extra binary and release-supply-chain dependency;
|
||||
- lowest-common-denominator normalization;
|
||||
- conflict with provider-native resume state;
|
||||
- an in-guest daemon is attacker-controlled after guest compromise;
|
||||
- duplicate orchestration responsibilities; and
|
||||
- upstream can move into policy/lifecycle and compete more directly.
|
||||
|
||||
**Security rule**
|
||||
|
||||
Treat every event and state claim from Sandbox Agent as untrusted telemetry.
|
||||
Never delegate egress authorization, credential release, bottle identity,
|
||||
authoritative audit, or Git policy to it.
|
||||
|
||||
### Option B — Implement a Sandbox Agent-compatible endpoint
|
||||
|
||||
bot-bottle maps the external protocol onto its existing provider adapters and
|
||||
process model without running the upstream server.
|
||||
|
||||
**Benefits**
|
||||
|
||||
- ecosystem compatibility with tighter component control;
|
||||
- no in-guest daemon dependency; and
|
||||
- room to preserve bot-bottle-native lifecycle semantics.
|
||||
|
||||
**Risks**
|
||||
|
||||
- large and continuing compatibility burden;
|
||||
- “full feature coverage” is expensive across all providers;
|
||||
- accidental protocol fork; and
|
||||
- effort diverted from policy and UX differentiation.
|
||||
|
||||
### Option C — Define an independent bot-bottle session API
|
||||
|
||||
Build only the control surface bot-bottle needs.
|
||||
|
||||
**Benefits**
|
||||
|
||||
- clean fit with the trust model and persistent named bottles;
|
||||
- no upstream dependency; and
|
||||
- deliberate support for supervision and security events.
|
||||
|
||||
**Risks**
|
||||
|
||||
- recreates a fast-growing open-source project;
|
||||
- no existing client ecosystem;
|
||||
- slower browser/desktop/mobile work; and
|
||||
- increases the chance that Sandbox Agent becomes the de facto standard first.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Start with **Option A as a bounded compatibility spike**, not a product
|
||||
commitment. Do not begin with a clean-room competing protocol.
|
||||
|
||||
The spike should answer:
|
||||
|
||||
1. Can Claude Code, Codex, and Pi retain exact native resume behavior?
|
||||
2. Can Sandbox Agent run without receiving real provider credentials?
|
||||
3. Can its server be reached through a bottle-scoped authenticated channel
|
||||
without exposing the orchestrator or broadening guest egress?
|
||||
4. Which permission events overlap or conflict with bot-bottle supervision?
|
||||
5. Can normalized events be stored while clearly separating untrusted
|
||||
transcript telemetry from authoritative gateway/Git audit?
|
||||
6. Can manifest skills, MCP servers, prompt, and startup arguments compile
|
||||
deterministically into its configuration?
|
||||
7. Does its versioning policy permit a compatibility contract bot-bottle can
|
||||
support?
|
||||
8. What image-size, startup-time, and update burden does the binary add?
|
||||
|
||||
If the answers are favorable, adopt it behind a bot-bottle-owned interface and
|
||||
pin/test the supported version. If not, implement the smallest compatible
|
||||
subset needed by external clients before inventing a wholly separate API.
|
||||
|
||||
## Product roadmap implications
|
||||
|
||||
The competitor scan and this architecture comparison reorder the likely work:
|
||||
|
||||
1. **Provider-neutral control/session compatibility spike**
|
||||
2. **Stable authenticated external bot-bottle API**
|
||||
3. **Normalized transcript/event persistence**
|
||||
4. **Parallel-session operator UI**
|
||||
5. **Browser/preview/computer-use capability**
|
||||
6. **Policy/image/skill distribution and signing**
|
||||
7. **Remote host/fleet management**
|
||||
|
||||
This does not mean pausing security work. It means exposing the shipped
|
||||
security work through a product surface that can compete with the SDK-plus-
|
||||
sandbox ecosystem.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat Sandbox Agent SDK as a potentially standard **agent process-control
|
||||
layer**, not as a sandbox replacement and not as a minor complementary
|
||||
library. Position bot-bottle one layer above it:
|
||||
|
||||
- manifests express trusted role and environment policy;
|
||||
- bot-bottle compiles and enforces that policy across host, gateway, Git, and
|
||||
isolation backends;
|
||||
- Sandbox Agent or a compatible protocol controls the selected agent process;
|
||||
and
|
||||
- bot-bottle owns the turnkey operator experience.
|
||||
|
||||
The Docker analogy is strategically sound when stated as:
|
||||
|
||||
> Sandbox Agent can be the portable task/exec protocol; bot-bottle can be the
|
||||
> opinionated engine, Compose-like policy layer, and Desktop-like operator
|
||||
> product.
|
||||
|
||||
It is not sound when stated as:
|
||||
|
||||
> Sandbox Agent is OCI and bot-bottle is Docker.
|
||||
|
||||
There is no independent OCI-equivalent agent specification yet, and Sandbox
|
||||
Agent already reaches into UI/session territory. Compatibility should be
|
||||
pursued quickly, while the trusted manifest/enforcement plane and operator
|
||||
experience remain the parts bot-bottle deliberately owns.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Testing a clean bot-bottle install on macOS
|
||||
|
||||
How do you exercise `install.sh` (and, ideally, a first `bot-bottle start`)
|
||||
the way a brand-new user would — on a pristine macOS environment you can
|
||||
throw away afterward — *without* permanently polluting your daily-driver
|
||||
Mac? The user's framing: is there a VM or boundary that avoids creating a
|
||||
separate account, or is spinning up and tearing down a throwaway macOS
|
||||
user on the CLI easy enough to just do that?
|
||||
|
||||
## Summary
|
||||
|
||||
There is no lightweight, in-place macOS sandbox that hands you a clean home
|
||||
directory and wipeable system state without *either* a VM or a separate
|
||||
user account. `sandbox-exec` (Seatbelt) is deprecated and confines a
|
||||
process, not an environment; App Sandbox is for shipping apps, not for
|
||||
provisioning a fresh dev host. So the real choice is exactly the two the
|
||||
user named: **a disposable macOS VM** or **a throwaway user account** —
|
||||
and which one is right turns on a detail specific to *this* project.
|
||||
|
||||
bot-bottle's default macOS backend is Apple's `container`, which runs each
|
||||
container in its own lightweight VM via `Virtualization.framework`
|
||||
([`README.md:27`](../README.md), [`apple-container-backend.md`](apple-container-backend.md)).
|
||||
That means a full end-to-end test — install *and* `bot-bottle start` —
|
||||
needs virtualization to work wherever bot-bottle runs. Inside a macOS guest
|
||||
VM that requires **nested virtualization, which Apple gates to M3 or newer
|
||||
chips on macOS 15+**. On M1/M2 you cannot run the Apple Container backend
|
||||
(or Docker Desktop, same reason) inside a macOS VM at all.
|
||||
|
||||
The recommendation splits on what you're testing and what silicon you have:
|
||||
|
||||
- **Install-script correctness only** (does `curl | sh` → pipx → config dir
|
||||
→ `doctor`'s Python/config checks pass?): a **disposable Tart VM** is the
|
||||
cleanest boundary and works on any Apple Silicon Mac. `doctor` will report
|
||||
the backend as not-ready inside the VM on M1/M2, which is fine — you're
|
||||
testing the installer, not the runtime.
|
||||
- **Full runtime** (actually launch a bottle) on **M3/M4**: a **disposable
|
||||
Tart VM from a golden base image, cloned per run** is the gold standard —
|
||||
a genuine kernel/state boundary that wipes to nothing.
|
||||
- **Full runtime** on **M1/M2**, or when you'd rather not fight nested virt:
|
||||
a **throwaway admin user via `sysadminctl`** is the pragmatic pick. It
|
||||
tests the real backend because the backend runs on the host hypervisor —
|
||||
but it is a *hygiene* boundary, not a security one, and it does **not**
|
||||
clean the system-level footprint (see below).
|
||||
|
||||
Prefer the VM. Reach for the throwaway user only when nested virt is off the
|
||||
table and you accept an imperfect wipe.
|
||||
|
||||
## Why "a boundary without a separate user" doesn't really exist on macOS
|
||||
|
||||
macOS has no namespace/overlay story like Linux `unshare` + tmpfs. The
|
||||
options that sound like in-place sandboxes don't fit:
|
||||
|
||||
| Mechanism | Why it doesn't give you a clean, wipeable env |
|
||||
|---|---|
|
||||
| `sandbox-exec` / Seatbelt | Officially deprecated; confines *one process's* syscalls against a profile. It cannot present a fresh `$HOME` or a pristine `/usr/local`, and it won't let the Apple Container system service work. |
|
||||
| App Sandbox | Entitlement-based confinement for signed `.app` bundles, not a provisioning tool for a CLI dev environment. |
|
||||
| A second `$HOME` via `HOME=/tmp/foo` | Redirects only what honors `$HOME`. `install.sh` mostly does (it writes `~/.bot-bottle` and pipx/pip `--user` paths), but the Apple `container` install lands in `/usr/local` + a **system service**, and Homebrew lands in `/opt/homebrew` — all outside any `$HOME` you set. You'd get a false sense of "clean." |
|
||||
| APFS snapshot rollback (`tmutil localsnapshot`) | You can't roll the live boot volume back to a local snapshot without booting to Recovery; it's not a per-run userspace undo. |
|
||||
|
||||
So the honest answer to "is there some boundary that avoids a separate
|
||||
user?": yes — a **VM** — and it's the *stronger* boundary anyway. The only
|
||||
lighter-weight option is the separate user, with the caveats below.
|
||||
|
||||
## What a clean install actually touches (the footprint that decides "wipeable")
|
||||
|
||||
Grounding the teardown story in what `install.sh` and the backend create:
|
||||
|
||||
| Artifact | Location | In `$HOME`? | Survives user deletion? |
|
||||
|---|---|---|---|
|
||||
| Config / state / db | `~/.bot-bottle/{agents,bottles,contrib,state,db}` ([`install.sh:80-83`](../install.sh), [`bot_bottle/paths.py:59`](../bot_bottle/paths.py)) | ✅ | ❌ removed with home |
|
||||
| pipx venv + shim | `~/.local/pipx/venvs/bot-bottle`, shim in `~/.local/bin` ([`install.sh:87-89`](../install.sh)) | ✅ | ❌ removed with home |
|
||||
| private venv fallback (no pipx) | `~/.bot-bottle/venv` + symlink in `~/.local/bin` ([`install.sh`](../install.sh)) | ✅ | ❌ removed with home |
|
||||
| PATH / token exports | shell profile (`~/.zprofile`, etc.); `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` ([`README.md:74`](../README.md)) | ✅ | ❌ removed with home |
|
||||
| **Apple `container` install** | `/usr/local/...` + notarized `.pkg` receipts | ❌ | ✅ **stays** |
|
||||
| Apple `container` **service state** | per-user: `~/Library/Application Support/com.apple.container/` (`container system start`) | ✅ | ❌ removed with home |
|
||||
| **Homebrew** (if used for `container`/python) | `/opt/homebrew` | ❌ | ✅ **stays** |
|
||||
| Rosetta 2 (needed for image builds) | system | ❌ | ✅ **stays** |
|
||||
|
||||
The bold rows are the crux: **deleting the throwaway user does not uninstall
|
||||
the Apple Container runtime, Homebrew, or Rosetta.** A VM, by contrast, wipes
|
||||
100% of the above by definition — that's its entire advantage for this task.
|
||||
|
||||
The service row is the exception, and a live harness run corrected it: the
|
||||
Apple `container` service is **per-user**, not a host-wide launchd service.
|
||||
`container system status` reports an `appRoot` under
|
||||
`~/Library/Application Support/com.apple.container/`, and a freshly created
|
||||
account sees `container system service: NOT running` even while the creating
|
||||
admin's is running. That cuts both ways — the state is genuinely removed with
|
||||
the home, so the reset is *more* complete than this table first claimed, but
|
||||
it also means **no brand-new user can run a bottle until they run
|
||||
`container system start` once**. `doctor` correctly fails until they do, which
|
||||
is why `test` judges backend readiness separately from install correctness.
|
||||
|
||||
## Option A — Disposable Tart VM (recommended)
|
||||
|
||||
[Tart](https://tart.run) is a CLI-first macOS/Linux VM manager built on
|
||||
`Virtualization.framework`, purpose-built for exactly this "does it work on
|
||||
a clean macOS, without my settings/permissions/data" workflow. Keep one
|
||||
pristine *golden* image, clone a throwaway per run, delete it after.
|
||||
|
||||
```sh
|
||||
brew install cirruslabs/cli/tart
|
||||
|
||||
# One-time: build a golden base (either a prebuilt image or a vanilla IPSW).
|
||||
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest golden # ~25 GB pull
|
||||
# — or a truly vanilla install you click through once —
|
||||
# tart create golden --from-ipsw latest --disk-size 60
|
||||
|
||||
# Per test run: clone → boot → test → destroy.
|
||||
tart clone golden test-run
|
||||
tart run test-run &
|
||||
ssh admin@"$(tart ip test-run)"
|
||||
# inside the guest:
|
||||
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
||||
# bot-bottle doctor
|
||||
tart stop test-run
|
||||
tart delete test-run # back to pristine; golden is untouched
|
||||
```
|
||||
|
||||
Cloning is cheap (sparse files), so the golden image is your reset button —
|
||||
every `tart clone` is a fresh macOS. This is the closest thing to a Linux
|
||||
`docker run --rm` for a whole Mac.
|
||||
|
||||
**The nested-virt caveat (read before relying on it for runtime tests).**
|
||||
The Apple Container backend inside the guest needs
|
||||
`Virtualization.framework` to work *inside* the VM. Apple enables nested
|
||||
virtualization only on **M3 or newer**, on **macOS 15 (Sequoia) or later**;
|
||||
M2 and earlier are excluded by Apple, confirmed by Apple DTS. Consequences:
|
||||
|
||||
- **M3/M4 host:** full runtime works in the guest. `bot-bottle doctor`
|
||||
reports the backend ready and `start` can launch a bottle. Gold standard.
|
||||
- **M1/M2 host:** the guest can install bot-bottle and pass the Python /
|
||||
config-dir checks, but `doctor`'s backend check will fail and you cannot
|
||||
launch a bottle in the VM. Still perfectly good for testing *the
|
||||
installer*; not for the runtime.
|
||||
- **M4-specific:** a known bug blocks pre-Ventura guests on M4; use a
|
||||
current macOS guest (which you want anyway, since Apple `container`
|
||||
targets macOS 26 Tahoe).
|
||||
|
||||
UTM is the GUI equivalent on the same framework (and was first to expose
|
||||
nested virt) if you'd rather click; Tart wins for a scriptable
|
||||
spin-up/tear-down loop.
|
||||
|
||||
## Option B — Throwaway user via `sysadminctl` (pragmatic fallback)
|
||||
|
||||
Creating and deleting a user from the CLI is genuinely a two-liner, and it
|
||||
tests the **real** backend on any Apple Silicon Mac because the backend runs
|
||||
on the host hypervisor — no nested virt needed.
|
||||
|
||||
```sh
|
||||
# Create a self-contained admin user (admin needed for the container service).
|
||||
sudo sysadminctl -addUser bbtest -fullName "bot-bottle test" \
|
||||
-password 'throwaway' -admin
|
||||
|
||||
# Log into that account (fast-user-switch or the login window), then run the
|
||||
# installer as bbtest exactly as a new user would. When done:
|
||||
|
||||
sudo sysadminctl -deleteUser bbtest -secure # -secure erases the home dir
|
||||
```
|
||||
|
||||
Honest accounting of what this does and doesn't buy you:
|
||||
|
||||
- **Boundary strength:** it's a *hygiene / fresh-`$HOME`* boundary, **not a
|
||||
security boundary.** Same kernel, same admin group; an admin test user can
|
||||
touch system state. If the point is "clean environment," fine. If the point
|
||||
is "contain something untrusted," this is the wrong tool — use a VM.
|
||||
- **Wipe completeness:** `-secure` erases the home dir (so `~/.bot-bottle`,
|
||||
the pipx venv, and profile exports go away), but as the footprint table
|
||||
shows, the **Apple Container runtime, its launchd system service,
|
||||
Homebrew, and Rosetta persist.** For a truly repeatable "did a *system with
|
||||
nothing installed* work?" test, that residue defeats the purpose — the
|
||||
second run isn't clean.
|
||||
- **Operational gotchas:** don't pass real passwords on the command line (they
|
||||
land in `ps` and history — this is a throwaway credential, so it's
|
||||
tolerable here). Deletion must run as root from a normally-booted, admin-
|
||||
logged-in session; the Terminal needs **Full Disk Access** or you'll hit
|
||||
error `-14120` and a half-deleted account. Prefer letting the system place
|
||||
the home dir (don't pass `-home`), or deletion can orphan it.
|
||||
|
||||
Use this when you're on M1/M2, you specifically want to exercise the live
|
||||
backend, and you can tolerate the system-level runtime staying installed
|
||||
between runs (or you uninstall Apple `container` / brew by hand to reset).
|
||||
|
||||
## Honorable mentions
|
||||
|
||||
- **External bootable macOS volume.** A fresh macOS on an external SSD (or a
|
||||
separate APFS volume) is bare-metal disposable: no nested-virt limit, real
|
||||
backend works, and you `diskutil` the volume away to reset. Cost is reboot
|
||||
friction per run — good for an occasional thorough pass, poor for a tight
|
||||
loop.
|
||||
- **Rented / cloud Mac.** AWS EC2 Mac (dedicated Mac minis), Scaleway Apple
|
||||
silicon, or MacStadium give a genuinely throwaway host you release when
|
||||
done. Overkill for local iteration, but this is essentially what the
|
||||
project's own advisory `integration-macos` CI job needs — a self-hosted
|
||||
Apple Silicon runner with the `container` CLI, Python ≥ 3.11, and coverage
|
||||
on the launchd service's PATH ([`README.md:78`](../README.md)). If you end
|
||||
up standing up a cloud Mac for install testing, it doubles as that runner.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Default to a **disposable Tart VM** — it's the only option that wipes the
|
||||
*entire* footprint (including the Apple Container system service that a user
|
||||
deletion leaves behind), it's a real boundary, and the spin-up/tear-down
|
||||
loop is a two-command `tart clone` / `tart delete`. Confirm your chip first:
|
||||
on **M3/M4** it tests install *and* runtime end-to-end; on **M1/M2** it still
|
||||
cleanly tests `install.sh` + `doctor`'s Python/config path, and you fall back
|
||||
to a **throwaway `sysadminctl` admin user** for live-backend testing —
|
||||
accepting that it's a hygiene boundary and that you'll manually uninstall the
|
||||
Apple Container runtime / Homebrew between runs to get back to truly clean.
|
||||
|
||||
There is no third, lighter-weight "in-place boundary without a user" that
|
||||
actually delivers a clean, wipeable macOS — the VM *is* that answer, and it's
|
||||
the better one.
|
||||
|
||||
## Harness
|
||||
|
||||
The throwaway-user loop is scripted in
|
||||
[`scripts/macos-install-test.sh`](../../scripts/macos-install-test.sh):
|
||||
`up` creates the account, `run` pipes *this checkout's* `install.sh` into it
|
||||
headlessly (so a PR is verifiable before it lands) and lets the installer run
|
||||
`doctor`, `down` deletes the account and its home (the full reset), and
|
||||
`deep-reset` additionally uninstalls the host `container` runtime. It leans on
|
||||
the footprint analysis above — the reset is just user deletion because
|
||||
everything `install.sh` writes is user-home-local.
|
||||
|
||||
There are two one-shot cycles, because "does the installer work" and "can a new
|
||||
user actually run a bottle" are different questions:
|
||||
|
||||
```sh
|
||||
sudo ./scripts/macos-install-test.sh test # up → run → status → down
|
||||
sudo ./scripts/macos-install-test.sh test-ready # ... + prereqs before status
|
||||
```
|
||||
|
||||
`test` models a macOS system **without** the prerequisites set up for this user
|
||||
— which is the default state of every new account, since the `container`
|
||||
service is per-user. It asserts the install is sound and *reports* backend
|
||||
readiness without failing on it, because install.sh provides no backend and so
|
||||
cannot regress one.
|
||||
|
||||
`test-ready` models a system **with** them, then demands doctor go fully green,
|
||||
backend included. Both variants pass as of this writing.
|
||||
|
||||
### The per-user prerequisite is two steps, not one
|
||||
|
||||
Running it revealed that "set up the backend for this account" is more than
|
||||
starting a service:
|
||||
|
||||
1. **`container system start`** — the service is per-user. The run confirms it
|
||||
directly: the throwaway account's `appRoot` is
|
||||
`/Users/bbtest/Library/Application Support/com.apple.container/`, its own,
|
||||
and starting it left the admin's service untouched.
|
||||
2. **A guest kernel**, which also lives in that per-user app root. A fresh
|
||||
account has none, so `container system start` prompts to download one —
|
||||
and *only* prompts, since the flags default to asking. Headless callers
|
||||
must pass `--enable-kernel-install` or the command dies on
|
||||
`failed to read user input`.
|
||||
|
||||
Neither step is done by `bot-bottle backend setup --backend=macos-container`,
|
||||
which only checks and then tells you to run `container system start` yourself.
|
||||
So a new account's real path to a working backend is:
|
||||
`container system start --enable-kernel-install`.
|
||||
|
||||
The harness must also enter the user's launchd domain via
|
||||
`launchctl asuser <uid>` to do any of this. `container system start` registers
|
||||
`com.apple.container.apiserver` as a per-user launchd agent and talks to it
|
||||
over XPC; from plain `sudo -u` the caller is still in root's bootstrap
|
||||
namespace, the lookup crosses domains, and the apiserver answers
|
||||
`invalidState: "unauthorized request"` even though the agent started fine.
|
||||
|
||||
It refuses to start against an existing account (a reused home is not a clean
|
||||
install), and it tears the account down from an `EXIT`/`INT` trap armed the
|
||||
moment the account exists, so a failed or Ctrl-C'd run still leaves the machine
|
||||
clean. Its verdict is deliberately stricter than the installer's own: note that
|
||||
`install.sh` exits **0** when it finishes but `doctor` reports unmet
|
||||
prerequisites, so "the installer succeeded" is not the assertion — `test` fails
|
||||
if the install fails, if `bot-bottle` never reached the new user's `PATH`, or if
|
||||
`doctor` is unhappy. `BB_TEST_KEEP=1` skips the teardown to poke at a failure.
|
||||
|
||||
### What a fresh account actually inherits
|
||||
|
||||
Expect the first honest run on a developer Mac to fail at the *Python* gate,
|
||||
and expect that to be correct. A new account's `PATH` is just `/etc/paths`
|
||||
(`/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin`),
|
||||
which notably does **not** include `/opt/homebrew/bin`. Homebrew's `shellenv`
|
||||
line lives in the *installing* user's `~/.zprofile` and is not inherited, so a
|
||||
throwaway user resolves `python3` to `/usr/bin/python3` — the Command Line
|
||||
Tools stub, still **3.9.6** on macOS 26 — and `install.sh` correctly dies on its
|
||||
`3.11+` requirement. Your own shell resolving `python3` to a 3.14 Homebrew
|
||||
build says nothing about what a new user sees; that gap is exactly what this
|
||||
harness exists to expose.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Apple Containers on macOS: technical comparison with Docker — The New Stack](https://thenewstack.io/apple-containers-on-macos-a-technical-comparison-with-docker/)
|
||||
- [How to Set Up Apple Containerization on macOS 26 — Stéphane Paquet](https://spaquet.medium.com/how-to-set-up-apple-containerization-on-macos-26-f870cc8c26cd)
|
||||
- [Install Apple Container CLI (macOS 15/26) — 4sysops](https://4sysops.com/archives/install-apple-container-cli-running-containers-natively-on-macos-15-sequoia-and-macos-26-tahoe/)
|
||||
- [Nested virtualization on Apple Silicon (M3+, macOS 15) — UTM issue #6700](https://github.com/utmapp/UTM/issues/6700)
|
||||
- [macOS 15 Sequoia nested virtualization for M3+ — Parallels Forums](https://forum.parallels.com/threads/macos-15-sequoia-nested-virtualization-for-m3-macs.364397/)
|
||||
- [M2 nested virtualization restriction (Apple DTS) — Apple Developer Forums](https://developer.apple.com/forums/thread/756723)
|
||||
- [M4 can't virtualize older macOS — Yahoo/Tech](https://tech.yahoo.com/computing/articles/m4-mac-computers-cant-virtualize-175122301.html)
|
||||
- [Tart — macOS/Linux VMs on Apple Silicon (Cirrus Labs)](https://tart.run/quick-start/)
|
||||
- [Tart GitHub](https://github.com/cirruslabs/tart)
|
||||
- [macOS VMs in a single command — frr.dev](https://www.frr.dev/posts/tart-macos-vms-from-terminal/)
|
||||
- [sysadminctl reference — SS64](https://ss64.com/mac/sysadminctl.html)
|
||||
- [User management from the macOS command line — macnotes](https://macnotes.wordpress.com/2019/03/28/user-management-create-remove-change-password-secure-token-from-macos-command-line/)
|
||||
+242
-55
@@ -8,14 +8,23 @@
|
||||
# pipx install bot-bottle # from a checkout or a published index
|
||||
# uv tool install bot-bottle
|
||||
#
|
||||
# This script is a thin bootstrapper: it checks prerequisites, installs the
|
||||
# package with pipx (falling back to pip --user), creates the config dir, and
|
||||
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
|
||||
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
|
||||
# what's missing after install.
|
||||
# This script is a thin bootstrapper: it finds a Python 3.11+ interpreter,
|
||||
# installs the package with pipx (falling back to a private venv), creates the
|
||||
# config dir, and runs `bot-bottle doctor`. It is idempotent (safe to re-run)
|
||||
# and never uses sudo. It does NOT install Docker or a VM backend for you —
|
||||
# `doctor` reports what's missing after install.
|
||||
#
|
||||
# Env:
|
||||
# BOT_BOTTLE_PYTHON interpreter to install with (skips the search)
|
||||
# BOT_BOTTLE_INSTALL_SPEC pip/git spec to install instead of the default
|
||||
# BOT_BOTTLE_CHANNEL production (default) or staging
|
||||
# BOT_BOTTLE_VERSION exact qualified vX.Y.Z[-rc.N] release
|
||||
# BOT_BOTTLE_REF exact 40-character commit snapshot (warns untested)
|
||||
# BOT_BOTTLE_VENV where the non-pipx install lives (~/.bot-bottle/venv)
|
||||
set -eu
|
||||
|
||||
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
|
||||
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-}"
|
||||
VENV="${BOT_BOTTLE_VENV:-${HOME}/.bot-bottle/venv}"
|
||||
MIN_PYTHON_MAJOR=3
|
||||
MIN_PYTHON_MINOR=11
|
||||
|
||||
@@ -28,21 +37,205 @@ die() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- prerequisites -----------------------------------------------------------
|
||||
# --- prerequisites: find an interpreter new enough ----------------------------
|
||||
|
||||
command -v python3 >/dev/null 2>&1 \
|
||||
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
|
||||
|
||||
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
|
||||
# Is $1 an interpreter that exists and meets the floor?
|
||||
python_ok() {
|
||||
[ -n "${1:-}" ] || return 1
|
||||
command -v "$1" >/dev/null 2>&1 || return 1
|
||||
"$1" - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' >/dev/null 2>&1
|
||||
import sys
|
||||
|
||||
want = (int(sys.argv[1]), int(sys.argv[2]))
|
||||
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Installing a `git+` spec (the default) shells out to git under the hood,
|
||||
# whether via pipx or pip. Fail early with a clear message rather than deep
|
||||
# inside the installer's output.
|
||||
python_version() {
|
||||
"$1" -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null
|
||||
}
|
||||
|
||||
# `python3` on PATH is often NOT the newest interpreter installed, and on macOS
|
||||
# it is usually the oldest: a fresh login shell's PATH is just /etc/paths, so
|
||||
# python3 resolves to the Command Line Tools stub (3.9.x) while the usable
|
||||
# 3.11+ build sits in /opt/homebrew/bin or a python.org framework directory,
|
||||
# reachable only via a line in the *installing* user's shell profile. A new
|
||||
# account inherits none of that. Look past PATH before giving up, so the common
|
||||
# case installs instead of dead-ending on a version error.
|
||||
find_python() {
|
||||
for candidate in \
|
||||
"${BOT_BOTTLE_PYTHON:-}" \
|
||||
python3 \
|
||||
python3.14 python3.13 python3.12 python3.11 \
|
||||
/opt/homebrew/bin/python3 \
|
||||
/usr/local/bin/python3 \
|
||||
"${HOME}/.local/bin/python3" \
|
||||
/Library/Frameworks/Python.framework/Versions/*/bin/python3
|
||||
do
|
||||
# An unmatched glob arrives here literally; python_ok rejects it.
|
||||
if python_ok "$candidate"; then
|
||||
command -v "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# An explicit choice that doesn't work is an error, not a reason to quietly
|
||||
# search elsewhere and install somewhere the caller didn't ask for.
|
||||
if [ -n "${BOT_BOTTLE_PYTHON:-}" ] && ! python_ok "${BOT_BOTTLE_PYTHON}"; then
|
||||
if command -v "${BOT_BOTTLE_PYTHON}" >/dev/null 2>&1; then
|
||||
die "BOT_BOTTLE_PYTHON=${BOT_BOTTLE_PYTHON} is $(python_version "${BOT_BOTTLE_PYTHON}"), "\
|
||||
"below the ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} floor. Unset it to search for a newer one."
|
||||
fi
|
||||
die "BOT_BOTTLE_PYTHON=${BOT_BOTTLE_PYTHON} is not an executable interpreter."
|
||||
fi
|
||||
|
||||
PYTHON="$(find_python || true)"
|
||||
|
||||
if [ -z "${PYTHON}" ]; then
|
||||
path_python="$(command -v python3 2>/dev/null || true)"
|
||||
if [ -n "${path_python}" ]; then
|
||||
found="the python3 on your PATH is ${path_python} ($(python_version "${path_python}")), which is too old"
|
||||
else
|
||||
found="no python3 was found on your PATH"
|
||||
fi
|
||||
case "$(uname -s)" in
|
||||
Darwin) fix=" brew install python@3.12
|
||||
# or install from https://www.python.org/downloads/macos/
|
||||
# macOS itself ships only /usr/bin/python3, which is too old" ;;
|
||||
*) fix=" sudo apt install python3.12 # Debian/Ubuntu
|
||||
sudo dnf install python3.12 # Fedora/RHEL" ;;
|
||||
esac
|
||||
die "bot-bottle needs python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer, and none was found.
|
||||
${found}.
|
||||
Also checked: python3.11-3.14, /opt/homebrew/bin, /usr/local/bin,
|
||||
~/.local/bin, and python.org framework builds.
|
||||
|
||||
Install a newer Python, then re-run this installer:
|
||||
${fix}
|
||||
|
||||
Already have one somewhere? Point at it directly:
|
||||
BOT_BOTTLE_PYTHON=/path/to/python3 sh install.sh"
|
||||
fi
|
||||
|
||||
# Be explicit when the interpreter isn't the obvious one, so nobody is left
|
||||
# wondering which Python their install ended up on.
|
||||
path_python="$(command -v python3 2>/dev/null || true)"
|
||||
if [ "${PYTHON}" != "${path_python}" ]; then
|
||||
say "using ${PYTHON} ($(python_version "${PYTHON}"))"
|
||||
if [ -n "${path_python}" ]; then
|
||||
say "note: 'python3' on your PATH is ${path_python} ($(python_version "${path_python}")), which is below the ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} floor"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- resolve an immutable published wheel -----------------------------------
|
||||
|
||||
if [ -z "${PACKAGE_SPEC}" ]; then
|
||||
selectors=0
|
||||
[ -n "${BOT_BOTTLE_REF:-}" ] && selectors=$((selectors + 1))
|
||||
[ -n "${BOT_BOTTLE_VERSION:-}" ] && selectors=$((selectors + 1))
|
||||
[ -n "${BOT_BOTTLE_CHANNEL:-}" ] && selectors=$((selectors + 1))
|
||||
[ "${selectors}" -le 1 ] || die \
|
||||
"BOT_BOTTLE_REF, BOT_BOTTLE_VERSION, and BOT_BOTTLE_CHANNEL are mutually exclusive."
|
||||
|
||||
downloads="${HOME}/.bot-bottle/downloads"
|
||||
mkdir -p "${downloads}"
|
||||
PACKAGE_SPEC="$("${PYTHON}" - \
|
||||
"${BOT_BOTTLE_REF:-}" \
|
||||
"${BOT_BOTTLE_VERSION:-}" \
|
||||
"${BOT_BOTTLE_CHANNEL:-production}" \
|
||||
"${downloads}" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ref, version, channel, download_root = sys.argv[1:]
|
||||
base = "https://gitea.dideric.is/api/packages/didericis/generic"
|
||||
|
||||
def fetch_json(url):
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
if ref:
|
||||
if re.fullmatch(r"[0-9a-f]{40}", ref) is None:
|
||||
raise SystemExit("bot-bottle install: error: BOT_BOTTLE_REF must be 40 lowercase hex")
|
||||
index_url = f"{base}/bot-bottle-builds/{ref}/bundle-index.json"
|
||||
print(
|
||||
"bot-bottle install: WARNING: installing an exact commit snapshot; "
|
||||
"it may not have passed release qualification.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
selector = f"commit {ref}"
|
||||
else:
|
||||
if version:
|
||||
if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?", version) is None:
|
||||
raise SystemExit("bot-bottle install: error: invalid BOT_BOTTLE_VERSION")
|
||||
pointer_url = f"{base}/bot-bottle-releases/{version}/release.json"
|
||||
selector = f"release {version}"
|
||||
else:
|
||||
if channel not in ("production", "staging"):
|
||||
raise SystemExit(
|
||||
"bot-bottle install: error: BOT_BOTTLE_CHANNEL must be production or staging")
|
||||
pointer_url = f"{base}/bot-bottle-channels/{channel}/channel.json"
|
||||
selector = f"{channel} channel"
|
||||
pointer = fetch_json(pointer_url)
|
||||
if pointer.get("schema") != 1 or pointer.get("qualified") is not True:
|
||||
raise SystemExit("bot-bottle install: error: release pointer is not qualified")
|
||||
index_url = pointer.get("bundle_index_url", "")
|
||||
if not isinstance(index_url, str) or not index_url.startswith("https://"):
|
||||
raise SystemExit("bot-bottle install: error: release pointer has no immutable bundle")
|
||||
|
||||
index = fetch_json(index_url)
|
||||
source_commit = index.get("source_commit", "")
|
||||
if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None:
|
||||
raise SystemExit("bot-bottle install: error: bundle has an invalid source commit")
|
||||
if ref and source_commit != ref:
|
||||
raise SystemExit("bot-bottle install: error: bundle does not match BOT_BOTTLE_REF")
|
||||
wheel = index.get("wheel")
|
||||
if not isinstance(wheel, dict):
|
||||
raise SystemExit("bot-bottle install: error: bundle has no wheel")
|
||||
filename, url, expected = (
|
||||
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
|
||||
if (
|
||||
not isinstance(filename, str)
|
||||
or re.fullmatch(r"[A-Za-z0-9_.-]+\.whl", filename) is None
|
||||
or not isinstance(url, str)
|
||||
or not url.startswith("https://")
|
||||
or not url.endswith("/" + filename)
|
||||
or not isinstance(expected, str)
|
||||
or re.fullmatch(r"[0-9a-f]{64}", expected) is None
|
||||
):
|
||||
raise SystemExit("bot-bottle install: error: bundle wheel metadata is invalid")
|
||||
|
||||
destination = Path(download_root) / source_commit / filename
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_suffix(destination.suffix + ".part")
|
||||
digest = hashlib.sha256()
|
||||
with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as output:
|
||||
while chunk := response.read(1 << 20):
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
if digest.hexdigest() != expected:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise SystemExit("bot-bottle install: error: downloaded wheel checksum mismatch")
|
||||
temporary.replace(destination)
|
||||
print(
|
||||
f"bot-bottle install: selected {selector} "
|
||||
f"(source {source_commit}, sha256 {expected[:16]}…)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(destination)
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
# An explicit `git+` override shells out to git under the hood, whether via
|
||||
# pipx or pip. Fail early with a clear message rather than deep inside the
|
||||
# installer's output.
|
||||
case "${PACKAGE_SPEC}" in
|
||||
git+*|*.git)
|
||||
command -v git >/dev/null 2>&1 || die \
|
||||
@@ -51,30 +244,6 @@ case "${PACKAGE_SPEC}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# The pip fallback needs a usable pip. Externally-managed interpreters
|
||||
# (PEP 668, common on Debian/Ubuntu/Homebrew) reject `pip install --user`;
|
||||
# pipx sidesteps that, so recommend it when pip can't be used.
|
||||
if ! command -v pipx >/dev/null 2>&1; then
|
||||
python3 -m pip --version >/dev/null 2>&1 || die \
|
||||
"neither pipx nor a usable 'python3 -m pip' was found. Install pipx "\
|
||||
"(recommended): 'python3 -m pip install --user pipx' or your OS package manager."
|
||||
if python3 - <<'PY'
|
||||
import os
|
||||
import sys
|
||||
import sysconfig
|
||||
|
||||
# PEP 668: an EXTERNALLY-MANAGED marker in the stdlib dir means pip refuses
|
||||
# to install into this interpreter without --break-system-packages.
|
||||
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
|
||||
raise SystemExit(0 if os.path.exists(marker) else 1)
|
||||
PY
|
||||
then
|
||||
die "this Python is externally managed (PEP 668), so 'pip install --user' is "\
|
||||
"blocked. Install pipx and re-run: 'python3 -m pip install --user --break-system-packages pipx', "\
|
||||
"then 'pipx ensurepath'."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- config directories ------------------------------------------------------
|
||||
|
||||
mkdir -p \
|
||||
@@ -84,32 +253,50 @@ mkdir -p \
|
||||
|
||||
# --- install -----------------------------------------------------------------
|
||||
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
|
||||
if command -v pipx >/dev/null 2>&1; then
|
||||
say "installing with pipx"
|
||||
pipx install --force "${PACKAGE_SPEC}"
|
||||
# --python pins the venv to the interpreter we vetted. Without it pipx uses
|
||||
# whichever Python it was itself installed with, which is not necessarily
|
||||
# the one that passed the version check above.
|
||||
say "installing with pipx (python: ${PYTHON})"
|
||||
pipx install --python "${PYTHON}" --force "${PACKAGE_SPEC}"
|
||||
# Ask pipx where it puts entry points rather than assuming ~/.local/bin.
|
||||
pipx_bin="$(pipx environment --value PIPX_BIN_DIR 2>/dev/null || true)"
|
||||
[ -n "${pipx_bin}" ] && BIN_DIR="${pipx_bin}"
|
||||
else
|
||||
say "pipx not found; installing with 'python3 -m pip install --user'"
|
||||
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
|
||||
# No `pip install --user` fallback: PEP 668 makes it unusable on nearly
|
||||
# every interpreter a Mac offers (Homebrew and python.org are both
|
||||
# externally managed), and on Debian/Ubuntu too. A private venv sidesteps
|
||||
# that entirely — PEP 668 does not apply inside a venv — and `venv` is
|
||||
# stdlib, so unlike pipx there is nothing to bootstrap first.
|
||||
say "pipx not found; installing into a managed venv at ${VENV}"
|
||||
"${PYTHON}" -m venv --clear "${VENV}" || die \
|
||||
"could not create a virtualenv at ${VENV} using ${PYTHON}. On Debian/Ubuntu "\
|
||||
"the venv module ships separately: 'sudo apt install python3-venv'."
|
||||
"${VENV}/bin/python" -m pip install --upgrade "${PACKAGE_SPEC}"
|
||||
# Expose the entry point outside the venv, the way pipx would.
|
||||
mkdir -p "${BIN_DIR}"
|
||||
ln -sf "${VENV}/bin/bot-bottle" "${BIN_DIR}/bot-bottle"
|
||||
fi
|
||||
|
||||
# --- locate the entry point --------------------------------------------------
|
||||
|
||||
# The pip --user scripts directory is platform-specific: ~/.local/bin on Linux,
|
||||
# but ~/Library/Python/<X.Y>/bin on a python.org macOS interpreter. Ask the
|
||||
# interpreter for its own user-scheme scripts dir instead of hardcoding.
|
||||
USER_SCRIPTS="$(python3 - <<'PY'
|
||||
import sysconfig
|
||||
print(sysconfig.get_path("scripts", sysconfig.get_preferred_scheme("user")))
|
||||
PY
|
||||
)"
|
||||
|
||||
if command -v bot-bottle >/dev/null 2>&1; then
|
||||
BOT_BOTTLE_BIN="bot-bottle"
|
||||
elif [ -n "${USER_SCRIPTS}" ] && [ -x "${USER_SCRIPTS}/bot-bottle" ]; then
|
||||
BOT_BOTTLE_BIN="${USER_SCRIPTS}/bot-bottle"
|
||||
say "note: add ${USER_SCRIPTS} to your PATH to run 'bot-bottle' directly"
|
||||
elif [ -x "${BIN_DIR}/bot-bottle" ]; then
|
||||
BOT_BOTTLE_BIN="${BIN_DIR}/bot-bottle"
|
||||
# Name the file the user's own login shell actually reads. ~/.profile is
|
||||
# the safe default for non-zsh: bash falls back to it, and suggesting
|
||||
# ~/.bash_profile could shadow an existing ~/.profile.
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) profile="~/.zprofile" ;;
|
||||
*) profile="~/.profile" ;;
|
||||
esac
|
||||
say "note: add ${BIN_DIR} to your PATH to run 'bot-bottle' directly:"
|
||||
say " echo 'export PATH=\"${BIN_DIR}:\$PATH\"' >> ${profile}"
|
||||
else
|
||||
die "bot-bottle was installed but is not on PATH; add ${USER_SCRIPTS:-your user scripts dir} to PATH and re-run"
|
||||
die "bot-bottle was installed but no entry point turned up in ${BIN_DIR}"
|
||||
fi
|
||||
|
||||
# --- verify ------------------------------------------------------------------
|
||||
|
||||
@@ -36,6 +36,7 @@ include = ["bot_bottle*"]
|
||||
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
|
||||
[tool.setuptools.package-data]
|
||||
bot_bottle = [
|
||||
"release-manifest.json",
|
||||
"gateway/egress/entrypoint.sh",
|
||||
"contrib/claude/Dockerfile",
|
||||
"contrib/claude/package.json",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# Why a pool + one-time setup: creating a TAP and assigning it an IP
|
||||
# needs CAP_NET_ADMIN. Pre-creating user-owned, pre-addressed TAPs
|
||||
# means `./cli.py start` never needs root. The nft table is static
|
||||
# means `bot-bottle start` never needs root. The nft table is static
|
||||
# (keyed on the `bbfc*` interface wildcard), so it covers every slot
|
||||
# without per-launch changes.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Generate the external commit-addressed release bundle index."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.release_bundle import build_bundle_index, canonical_bytes
|
||||
from bot_bottle.release_publish import bundle_url, publish_bundle
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--wheel", required=True, type=Path)
|
||||
parser.add_argument("--wheel-url")
|
||||
parser.add_argument("--workflow-run", required=True)
|
||||
parser.add_argument("--published-at", required=True)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--publish", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
index = build_bundle_index(
|
||||
manifest,
|
||||
wheel=args.wheel,
|
||||
wheel_url=args.wheel_url or bundle_url(
|
||||
manifest["source_commit"], args.wheel.name),
|
||||
workflow_run=args.workflow_run,
|
||||
published_at=args.published_at,
|
||||
)
|
||||
args.output.write_bytes(canonical_bytes(index))
|
||||
if args.publish:
|
||||
publish_bundle(index, args.wheel)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Generate the validated infrastructure manifest consumed by package builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.release_manifest import parse_manifest
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser()
|
||||
result.add_argument("--source-commit", required=True)
|
||||
result.add_argument("--orchestrator-image", required=True)
|
||||
result.add_argument("--gateway-image", required=True)
|
||||
for provider in ("claude", "codex", "pi"):
|
||||
result.add_argument(f"--agent-{provider}-image", required=True)
|
||||
for role in ("orchestrator", "gateway"):
|
||||
result.add_argument(f"--firecracker-{role}-version", required=True)
|
||||
result.add_argument(f"--firecracker-{role}-sha256", required=True)
|
||||
result.add_argument("--output", type=Path, required=True)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parser().parse_args(argv)
|
||||
data = {
|
||||
"schema": 1,
|
||||
"source_commit": args.source_commit,
|
||||
"oci": {
|
||||
"orchestrator": args.orchestrator_image,
|
||||
"gateway": args.gateway_image,
|
||||
**{
|
||||
f"agent_{provider}": getattr(args, f"agent_{provider}_image")
|
||||
for provider in ("claude", "codex", "pi")
|
||||
},
|
||||
},
|
||||
"firecracker": {
|
||||
role: {
|
||||
"version": getattr(args, f"firecracker_{role}_version"),
|
||||
"sha256": getattr(args, f"firecracker_{role}_sha256"),
|
||||
}
|
||||
for role in ("orchestrator", "gateway")
|
||||
},
|
||||
}
|
||||
parse_manifest(data)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+531
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env bash
|
||||
# Clean-install test harness for the macOS (Apple `container`) path.
|
||||
#
|
||||
# Exercises install.sh the way a brand-new user would, inside a throwaway
|
||||
# macOS account you create and delete from the CLI. install.sh's entire
|
||||
# footprint is user-home-local — the pipx venv under ~/.local, or the private
|
||||
# venv at ~/.bot-bottle/venv plus a ~/.local/bin symlink, and the ~/.bot-bottle
|
||||
# config dir. It writes no shell-profile PATH line, and never installs the
|
||||
# backend (see
|
||||
# the header of install.sh), so deleting the user is a complete,
|
||||
# deterministic reset of everything the installer touched. The Apple
|
||||
# `container` runtime is a HOST prerequisite installed once and kept;
|
||||
# `deep-reset` is the rare escape hatch that also removes it.
|
||||
#
|
||||
# Why a throwaway user and not a disposable VM: bot-bottle's default macOS
|
||||
# backend is Apple `container`, which runs each container in its own
|
||||
# Virtualization.framework microVM. Running that backend inside a macOS
|
||||
# guest VM needs nested virtualization, which Apple gates to M3+ silicon.
|
||||
# On M1/M2 a separate user account is the only way to get a clean $HOME
|
||||
# while still reaching the real host backend. Full rationale in
|
||||
# docs/research/testing-clean-install-on-macos.md.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./scripts/macos-install-test.sh test # up -> run -> status -> down
|
||||
# sudo ./scripts/macos-install-test.sh test-ready # ... with prereqs satisfied
|
||||
# sudo ./scripts/macos-install-test.sh up # create the throwaway user
|
||||
# sudo ./scripts/macos-install-test.sh run # run install.sh (+doctor) as it
|
||||
# sudo ./scripts/macos-install-test.sh prereqs # start the per-user backend svc
|
||||
# ./scripts/macos-install-test.sh status # user present? backend ready?
|
||||
# sudo ./scripts/macos-install-test.sh down # delete user + home (the reset)
|
||||
# sudo ./scripts/macos-install-test.sh deep-reset # ALSO uninstall host `container`
|
||||
#
|
||||
# TWO TEST VARIANTS, because "does the installer work" and "can a new user
|
||||
# actually run a bottle" are different questions with different answers:
|
||||
#
|
||||
# test A macOS system WITHOUT the prerequisites set up for this user —
|
||||
# the default state of any brand-new account, since the Apple
|
||||
# `container` service is per-user. Asserts the INSTALL is sound
|
||||
# (entry point present, doctor runs without crashing, python and
|
||||
# config check out). Backend readiness is reported, not required:
|
||||
# install.sh does not provide a backend and cannot regress one,
|
||||
# so failing on it would make this permanently red.
|
||||
#
|
||||
# test-ready A macOS system WITH the prerequisites satisfied — it starts the
|
||||
# per-user `container` service (and installs its guest kernel)
|
||||
# for the throwaway user first, then demands doctor go fully
|
||||
# green, backend included. This is the end-to-end claim: a new
|
||||
# user can actually run a bottle. Note it downloads a guest
|
||||
# kernel per run, because the previous run's went with the
|
||||
# deleted home; BB_TEST_KERNEL_INSTALL=0 skips that.
|
||||
#
|
||||
# Both refuse to start if the account already exists (a reused home is not a
|
||||
# clean install), and both tear the account down on the way out however they
|
||||
# exit, so a failed run never leaves an orphan behind. install.sh itself exits
|
||||
# 0 when doctor reports unmet prerequisites, so either variant is a stricter
|
||||
# gate than running the installer by hand.
|
||||
#
|
||||
# Config via env:
|
||||
# BB_TEST_USER account short name (default: bbtest)
|
||||
# BB_TEST_FULLNAME account full name (default: "bot-bottle install test")
|
||||
# BB_TEST_ADMIN 1=admin (reach container svc), 0=standard (default: 1)
|
||||
# BB_TEST_INSTALL_URL curl this install.sh instead of piping the local checkout
|
||||
# BB_TEST_KEEP 1=`test` skips its teardown, to poke at a failure
|
||||
# BB_TEST_REQUIRE_BACKEND 1=`test` also fails when no backend is ready
|
||||
# BB_TEST_KERNEL_INSTALL 0=`test-ready` skips the guest-kernel download
|
||||
# BB_TEST_REPO_URL https repo the throwaway user clones (default: this one)
|
||||
# BOT_BOTTLE_INSTALL_SPEC passed through to install.sh (pip / git spec)
|
||||
#
|
||||
# Notes:
|
||||
# * Run from a normally-booted admin session. Grant Terminal *Full Disk
|
||||
# Access* (System Settings -> Privacy & Security) or `down` half-fails
|
||||
# with error -14120 and leaves an orphaned account.
|
||||
# * `sysadminctl` always exits 0 even on failure, so `up`/`down` verify
|
||||
# the result with `dscl` and fail loudly on a mismatch.
|
||||
# * The account is created without a password: `run` drives it headlessly
|
||||
# via `sudo -u`, which never needs the target's password. The account
|
||||
# cannot GUI-login, which this harness does not require.
|
||||
# * `run` covers the installer + `bot-bottle doctor`. Actually launching a
|
||||
# bottle from the throwaway user may need a full launchd user session
|
||||
# (`launchctl asuser`); on M1/M2 the backend can't run under nested virt
|
||||
# anyway, so this harness stops at install + doctor.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
USER_NAME="${BB_TEST_USER:-bbtest}"
|
||||
FULL_NAME="${BB_TEST_FULLNAME:-bot-bottle install test}"
|
||||
ADMIN="${BB_TEST_ADMIN:-1}"
|
||||
# https, not the ssh `origin`: the throwaway user has no key of ours.
|
||||
BB_TEST_REPO_URL="${BB_TEST_REPO_URL:-https://gitea.dideric.is/didericis/bot-bottle.git}"
|
||||
|
||||
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_REPO_ROOT="$(cd "$_SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Set by `test`, which chains the steps itself and so suppresses the
|
||||
# "here's the next command to run" hints the individual steps print.
|
||||
IN_TEST=0
|
||||
|
||||
# --- guards ----------------------------------------------------------
|
||||
require_macos() {
|
||||
[ "$(uname -s)" = "Darwin" ] \
|
||||
|| { echo "error: this harness is macOS-only (uname is $(uname -s))" >&2; exit 1; }
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "error: '$1' needs root; re-run under sudo" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
user_exists() { dscl . -read "/Users/$USER_NAME" >/dev/null 2>&1; }
|
||||
|
||||
user_uid() {
|
||||
dscl . -read "/Users/$USER_NAME" UniqueID 2>/dev/null | awk '{print $2}'
|
||||
}
|
||||
|
||||
# Whether we can enter the target user's launchd domain.
|
||||
#
|
||||
# This matters more than it sounds. `container system start` registers
|
||||
# com.apple.container.apiserver as a per-USER launchd agent and then talks to
|
||||
# it over XPC. Under plain `sudo -u` the caller is still in root's bootstrap
|
||||
# namespace, so the lookup crosses domains and the apiserver answers
|
||||
# invalidState: "unauthorized request"
|
||||
# even though it launched fine. `launchctl asuser <uid>` puts the whole
|
||||
# invocation in that user's domain, which is also what a real user gets from
|
||||
# Terminal — so it is the more faithful way to run *everything* here, not just
|
||||
# the service start. Falls back to plain sudo when unavailable, since a
|
||||
# never-logged-in account may have no bootstrappable domain at all.
|
||||
_SESSION_OK=""
|
||||
user_session_available() {
|
||||
if [ -z "$_SESSION_OK" ]; then
|
||||
local uid
|
||||
uid="$(user_uid)"
|
||||
if [ -n "$uid" ] && launchctl asuser "$uid" true >/dev/null 2>&1; then
|
||||
_SESSION_OK=1
|
||||
else
|
||||
_SESSION_OK=0
|
||||
fi
|
||||
fi
|
||||
[ "$_SESSION_OK" = "1" ]
|
||||
}
|
||||
|
||||
# Run a shell snippet as the throwaway user in a fresh login shell.
|
||||
#
|
||||
# The snippet goes in on stdin, never as an argument. `sudo -i` joins its argv
|
||||
# into a single string and hands that to the login shell's -c, so quoting in an
|
||||
# argument is not preserved and a newline ends the command outright ("sh: -c:
|
||||
# line 1: syntax error: unexpected end of file"). Feeding `sh -s` from stdin
|
||||
# sidesteps the joining entirely and lets snippets be multi-line.
|
||||
run_as_user() {
|
||||
if user_session_available; then
|
||||
printf '%s\n' "$1" | launchctl asuser "$(user_uid)" sudo -u "$USER_NAME" -i sh -s
|
||||
else
|
||||
printf '%s\n' "$1" | sudo -u "$USER_NAME" -i sh -s
|
||||
fi
|
||||
}
|
||||
|
||||
# `bot-bottle doctor` as the throwaway user. Non-zero when no entry point was
|
||||
# installed at all, or when doctor itself is unhappy.
|
||||
#
|
||||
# Deliberately does NOT require `bot-bottle` on PATH: install.sh prints the
|
||||
# PATH line rather than editing a shell profile, so on a fresh account the
|
||||
# entry point is installed and working but not on PATH. Demanding PATH here
|
||||
# would fail every run for a reason the installer intends.
|
||||
#
|
||||
# Doctor's own exit code conflates two unrelated things: whether the install
|
||||
# works, and whether this user has a backend ready to run a bottle. Those need
|
||||
# different verdicts here. install.sh explicitly does not install a backend,
|
||||
# and the Apple `container` service is per-USER — its state lives in
|
||||
# ~/Library/Application Support/com.apple.container — so a brand-new account
|
||||
# never has one running, no matter how correct the install is. Failing on that
|
||||
# would leave `test` permanently red for something the installer cannot fix
|
||||
# and cannot regress. So: assert the install, report the backend.
|
||||
# BB_TEST_REQUIRE_BACKEND=1 makes backend readiness fatal too.
|
||||
doctor_as_user() {
|
||||
local out rc=0 bad=0
|
||||
out="$(mktemp "${TMPDIR:-/tmp}/bb-doctor.XXXXXX")"
|
||||
# shellcheck disable=SC2016 # $HOME/$bb must expand in the *target* user's
|
||||
# shell, not in this one — that's the whole point of the single quotes.
|
||||
run_as_user '
|
||||
for bb in bot-bottle "$HOME/.local/bin/bot-bottle" "$HOME/.bot-bottle/venv/bin/bot-bottle"; do
|
||||
if command -v "$bb" >/dev/null 2>&1; then
|
||||
case "$bb" in
|
||||
bot-bottle) : ;;
|
||||
*) echo " (not on PATH — running $bb directly, as install.sh advises)" ;;
|
||||
esac
|
||||
exec "$bb" doctor
|
||||
fi
|
||||
done
|
||||
echo " no bot-bottle entry point found for this user" >&2
|
||||
exit 1
|
||||
' >"$out" 2>&1 || rc=$?
|
||||
cat "$out"
|
||||
|
||||
# An unhandled exception is always an install/product defect, never an
|
||||
# environment fact — this is exactly how the PermissionError on `ip` showed
|
||||
# up, and doctor's non-zero exit alone would not have distinguished it.
|
||||
if grep -q 'Traceback (most recent call last)' "$out"; then
|
||||
echo " doctor crashed (traceback above) — a defect, not a missing prerequisite" >&2
|
||||
bad=1
|
||||
fi
|
||||
grep -qE '^ok: +python:' "$out" \
|
||||
|| { echo " doctor never reported a usable python" >&2; bad=1; }
|
||||
grep -qE '^ok: +config:' "$out" \
|
||||
|| { echo " doctor never reported a usable config dir" >&2; bad=1; }
|
||||
if [ "$rc" -ne 0 ] && ! grep -qE '^(fail|warn): +backend' "$out"; then
|
||||
echo " doctor failed for something other than backend readiness" >&2
|
||||
bad=1
|
||||
fi
|
||||
|
||||
local backend_ready=1
|
||||
grep -qE '^fail: +backend' "$out" && backend_ready=0
|
||||
rm -f "$out"
|
||||
|
||||
[ "$bad" -eq 0 ] || return 1
|
||||
if [ "$backend_ready" -eq 0 ]; then
|
||||
if [ "${BB_TEST_REQUIRE_BACKEND:-0}" = "1" ]; then
|
||||
echo " no backend is ready and BB_TEST_REQUIRE_BACKEND=1" >&2
|
||||
return 1
|
||||
fi
|
||||
echo " note: install is sound; no backend ready for this user."
|
||||
echo " Expected on a fresh account — the Apple 'container' service is"
|
||||
echo " per-user and needs one 'container system start'. Set"
|
||||
echo " BB_TEST_REQUIRE_BACKEND=1 to treat this as a failure."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- commands --------------------------------------------------------
|
||||
cmd_up() {
|
||||
require_macos
|
||||
require_root up
|
||||
if user_exists; then
|
||||
echo "$USER_NAME already exists; nothing to do (run 'down' first to reset)"
|
||||
return 0
|
||||
fi
|
||||
local admin_flag=()
|
||||
[ "$ADMIN" = "1" ] && admin_flag=(-admin)
|
||||
# No -password: the account is only ever driven headlessly via `sudo -u`,
|
||||
# which doesn't need one. sysadminctl warns about FileVault here; that's
|
||||
# irrelevant to a headless test account.
|
||||
sysadminctl -addUser "$USER_NAME" -fullName "$FULL_NAME" "${admin_flag[@]}" || true
|
||||
# sysadminctl exits 0 regardless of outcome, so confirm the account landed.
|
||||
user_exists || { echo "error: failed to create $USER_NAME" >&2; return 1; }
|
||||
if [ "$IN_TEST" = 1 ]; then
|
||||
echo "created $USER_NAME (admin=$ADMIN)"
|
||||
else
|
||||
echo "created $USER_NAME (admin=$ADMIN). Install into it with: sudo $0 run"
|
||||
fi
|
||||
}
|
||||
|
||||
# The package spec to install. install.sh's own default is the repo's DEFAULT
|
||||
# BRANCH, which would mean piping this checkout's installer into the throwaway
|
||||
# user and then installing main's code — verifying the PR's install.sh against
|
||||
# a package that doesn't contain the PR. Pin to the branch under test instead.
|
||||
#
|
||||
# The branch has to be pushed: the throwaway user clones over https and cannot
|
||||
# read this checkout (mode-700 home, and no SSH key for the ssh remote), so
|
||||
# what it gets is whatever the remote has. Say so when that differs from here.
|
||||
resolve_install_spec() {
|
||||
if [ -n "${BOT_BOTTLE_INSTALL_SPEC:-}" ]; then
|
||||
echo "$BOT_BOTTLE_INSTALL_SPEC"
|
||||
return 0
|
||||
fi
|
||||
local branch
|
||||
branch="$(git -C "$_REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" || branch=""
|
||||
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
|
||||
echo "note: not on a named branch; installing the repo default" >&2
|
||||
echo "git+${BB_TEST_REPO_URL}"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$(git -C "$_REPO_ROOT" status --porcelain 2>/dev/null)" ]; then
|
||||
echo "warning: working tree is dirty — the throwaway user installs" >&2
|
||||
echo " $branch as pushed, not what is on disk here." >&2
|
||||
elif ! git -C "$_REPO_ROOT" diff --quiet "@{upstream}" 2>/dev/null; then
|
||||
echo "warning: $branch differs from its upstream — push first, or the" >&2
|
||||
echo " throwaway user installs stale code." >&2
|
||||
fi
|
||||
echo "git+${BB_TEST_REPO_URL}@${branch}"
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
require_macos
|
||||
require_root run
|
||||
user_exists || { echo "error: $USER_NAME does not exist; run 'sudo $0 up' first" >&2; return 1; }
|
||||
|
||||
local spec
|
||||
spec="$(resolve_install_spec)"
|
||||
|
||||
echo "== installing bot-bottle as $USER_NAME =="
|
||||
echo "== spec: $spec =="
|
||||
if [ -n "${BB_TEST_INSTALL_URL:-}" ]; then
|
||||
run_as_user "BOT_BOTTLE_INSTALL_SPEC='$spec' curl -fsSL '$BB_TEST_INSTALL_URL' | sh"
|
||||
else
|
||||
# Test THIS checkout's install.sh, not the published one, so a PR is
|
||||
# verifiable before it lands. Feed it in on stdin rather than staging a
|
||||
# copy somewhere the throwaway user can read: the redirect is opened by
|
||||
# root before sudo drops privileges, so the tester's mode-700 home is a
|
||||
# non-issue, there's no temp file to leak if the run is interrupted, and
|
||||
# `sh -s` is the same shape as the documented `curl … | sh` install.
|
||||
#
|
||||
# The spec is prepended to the stream as an export rather than passed
|
||||
# as an argument, for the same argv-joining reason as run_as_user.
|
||||
{
|
||||
printf "export BOT_BOTTLE_INSTALL_SPEC='%s'\n" "$spec"
|
||||
cat "$_REPO_ROOT/install.sh"
|
||||
} | sudo -u "$USER_NAME" -i sh -s
|
||||
fi
|
||||
[ "$IN_TEST" = 1 ] \
|
||||
|| echo "== install.sh runs 'doctor' itself; re-check anytime with: $0 status =="
|
||||
}
|
||||
|
||||
# Informational, with one teeth-bearing case: when it can actually reach
|
||||
# doctor (root, account present) its exit status is doctor's, so `test` and
|
||||
# any other caller can use it as the post-install assertion.
|
||||
cmd_status() {
|
||||
require_macos
|
||||
local rc=0
|
||||
if user_exists; then
|
||||
echo "user: $USER_NAME present"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "doctor (as $USER_NAME):"
|
||||
doctor_as_user || rc=1
|
||||
else
|
||||
echo " (re-run under sudo to run 'bot-bottle doctor' as $USER_NAME)"
|
||||
fi
|
||||
else
|
||||
echo "user: $USER_NAME absent"
|
||||
fi
|
||||
if command -v container >/dev/null 2>&1; then
|
||||
echo "backend: apple 'container' present ($(container --version 2>/dev/null | head -1))"
|
||||
else
|
||||
echo "backend: apple 'container' NOT on PATH (host prerequisite; install once)"
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
require_macos
|
||||
require_root down
|
||||
if ! user_exists; then
|
||||
echo "$USER_NAME not present; nothing to remove"
|
||||
return 0
|
||||
fi
|
||||
# A plain -deleteUser removes the home dir, which is the whole reset.
|
||||
# -secure is a no-op on modern macOS (secure erase of the home folder
|
||||
# was removed in Sierra), so it buys nothing here.
|
||||
sysadminctl -deleteUser "$USER_NAME" || true
|
||||
if user_exists; then
|
||||
echo "error: $USER_NAME still present after delete." >&2
|
||||
echo " - grant Terminal Full Disk Access (System Settings > Privacy & Security), or" >&2
|
||||
echo " - it may hold the last Secure Token (won't happen while another admin exists)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "removed $USER_NAME and its home — install surface is clean."
|
||||
}
|
||||
|
||||
# Satisfy the throwaway user's backend prerequisites.
|
||||
#
|
||||
# `bot-bottle backend setup --backend=macos-container` deliberately does NOT do
|
||||
# this: it only checks, then tells you to run `container system start` yourself
|
||||
# (see backend/macos_container/setup.py — "no privileged host setup required").
|
||||
# So the harness runs the command the product asks for, as the user who needs
|
||||
# it, which is the whole prerequisite for the macOS backend given the CLI is
|
||||
# already installed host-wide.
|
||||
cmd_prereqs() {
|
||||
require_macos
|
||||
require_root prereqs
|
||||
user_exists || { echo "error: $USER_NAME does not exist" >&2; return 1; }
|
||||
command -v container >/dev/null 2>&1 || {
|
||||
echo "error: the Apple 'container' CLI is not on PATH. It is a HOST" >&2
|
||||
echo " prerequisite this harness does not install; see the header." >&2
|
||||
return 1
|
||||
}
|
||||
echo "starting the per-user Apple 'container' service for $USER_NAME"
|
||||
user_session_available || {
|
||||
echo "warning: no launchd session for $USER_NAME; the service start is" >&2
|
||||
echo " likely to fail with 'unauthorized request'. See user_session_available." >&2
|
||||
}
|
||||
# The kernel flag is not optional for a headless run. `container system
|
||||
# start` PROMPTS for the default Linux kernel when neither flag is given
|
||||
# ("default: prompt user"), and a throwaway account has no kernel because
|
||||
# it lives in the per-user app root — so the prompt always fires here and
|
||||
# dies on "failed to read user input" with no TTY.
|
||||
#
|
||||
# Enabling it is also the honest choice for what this variant claims: the
|
||||
# backend cannot actually run a bottle without a guest kernel. The cost is
|
||||
# that every test-ready run downloads one afresh, since the previous run's
|
||||
# copy went with the deleted home. BB_TEST_KERNEL_INSTALL=0 skips the
|
||||
# download when you only care that the service comes up.
|
||||
local kernel_flag=--enable-kernel-install
|
||||
[ "${BB_TEST_KERNEL_INSTALL:-1}" = "0" ] && kernel_flag=--disable-kernel-install
|
||||
echo " ($kernel_flag)"
|
||||
run_as_user "container system start $kernel_flag" || {
|
||||
echo "error: 'container system start' failed for $USER_NAME" >&2
|
||||
echo " invalidState/\"unauthorized request\" means the CLI could not reach" >&2
|
||||
echo " its per-user apiserver over XPC — a never-GUI-logged-in account may" >&2
|
||||
echo " have no usable launchd domain; log into $USER_NAME once to get one." >&2
|
||||
echo " A download failure means the guest kernel could not be fetched; retry" >&2
|
||||
echo " with network, or BB_TEST_KERNEL_INSTALL=0 to skip it." >&2
|
||||
return 1
|
||||
}
|
||||
run_as_user 'container system status' || {
|
||||
echo "error: the service is still not running for $USER_NAME" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# Teardown half of the test cycles, installed as an EXIT trap the moment the
|
||||
# account exists so a failure — or a Ctrl-C — still leaves the machine clean.
|
||||
_test_teardown() {
|
||||
local rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if [ "${BB_TEST_KEEP:-0}" = "1" ]; then
|
||||
echo
|
||||
echo "== [$_STEPS/$_STEPS] down: SKIPPED (BB_TEST_KEEP=1) =="
|
||||
echo " $USER_NAME is still around; remove it with: sudo $0 down"
|
||||
exit "$rc"
|
||||
fi
|
||||
echo
|
||||
echo "== [$_STEPS/$_STEPS] down =="
|
||||
cmd_down || rc=1
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo
|
||||
echo "PASS: $_PASS_CLAIM"
|
||||
else
|
||||
echo
|
||||
echo "FAIL: see above (the throwaway account was torn down regardless)." >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
# The two variants differ only in whether the backend prerequisites get
|
||||
# satisfied before doctor runs, which is exactly the question each one asks:
|
||||
#
|
||||
# test a brand-new user on a machine where nothing has been set up for
|
||||
# them. Asserts the INSTALL is sound; backend readiness is
|
||||
# reported, not required, because install.sh does not provide a
|
||||
# backend and cannot regress one.
|
||||
# test-ready the same user with the documented prerequisites satisfied.
|
||||
# Asserts doctor goes fully green, backend included — the
|
||||
# end-to-end "a new user can actually run a bottle" claim.
|
||||
_test_cycle() {
|
||||
local with_prereqs="$1"
|
||||
require_macos
|
||||
require_root test
|
||||
# A pre-existing account means a pre-existing home, which is the one thing
|
||||
# this harness exists to rule out. Don't silently test a dirty install.
|
||||
if user_exists; then
|
||||
echo "error: $USER_NAME already exists, so this would not be a clean install." >&2
|
||||
echo " reset first: sudo $0 down" >&2
|
||||
return 1
|
||||
fi
|
||||
IN_TEST=1
|
||||
|
||||
echo "== [1/$_STEPS] up =="
|
||||
cmd_up
|
||||
trap _test_teardown EXIT INT TERM
|
||||
|
||||
echo
|
||||
echo "== [2/$_STEPS] run =="
|
||||
cmd_run
|
||||
|
||||
if [ "$with_prereqs" = 1 ]; then
|
||||
echo
|
||||
echo "== [3/$_STEPS] prereqs =="
|
||||
cmd_prereqs || {
|
||||
echo "error: could not satisfy the backend prerequisites (see above)." >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== [$((_STEPS - 1))/$_STEPS] status =="
|
||||
# install.sh exits 0 even when doctor reports unmet prerequisites, so the
|
||||
# install succeeding is not the verdict — this is.
|
||||
cmd_status || {
|
||||
echo "error: doctor is unhappy for a freshly installed user (see above)." >&2
|
||||
echo " re-run with BB_TEST_KEEP=1 to keep $USER_NAME around and dig in." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
_STEPS=4
|
||||
_PASS_CLAIM="a brand-new user can install bot-bottle; the install is sound."
|
||||
_test_cycle 0
|
||||
}
|
||||
|
||||
cmd_test_ready() {
|
||||
_STEPS=5
|
||||
_PASS_CLAIM="a brand-new user with the prerequisites met passes doctor outright."
|
||||
# With the prerequisites satisfied there is no excuse for an unready
|
||||
# backend, so this variant demands the thing `test` only reports.
|
||||
BB_TEST_REQUIRE_BACKEND=1
|
||||
_test_cycle 1
|
||||
}
|
||||
|
||||
cmd_deep_reset() {
|
||||
require_macos
|
||||
require_root deep-reset
|
||||
# Remove the user first (idempotent), then the HOST-level container
|
||||
# runtime that a user deletion leaves behind under /usr/local + launchd.
|
||||
cmd_down || true
|
||||
if command -v container >/dev/null 2>&1; then
|
||||
# The service can run in more than one launchd context (the invoking
|
||||
# user's and root's), so stop both, best-effort.
|
||||
[ -n "${SUDO_USER:-}" ] && sudo -u "$SUDO_USER" container system stop 2>/dev/null || true
|
||||
container system stop 2>/dev/null || true
|
||||
if [ -x /usr/local/bin/uninstall-container.sh ]; then
|
||||
/usr/local/bin/uninstall-container.sh -d || true
|
||||
echo "uninstalled the host Apple 'container' runtime"
|
||||
else
|
||||
echo "note: /usr/local/bin/uninstall-container.sh not found; runtime left as-is" >&2
|
||||
fi
|
||||
else
|
||||
echo "no 'container' runtime on PATH; nothing further to remove"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
test) cmd_test ;;
|
||||
test-ready) cmd_test_ready ;;
|
||||
up) cmd_up ;;
|
||||
run) cmd_run ;;
|
||||
prereqs) cmd_prereqs ;;
|
||||
status) cmd_status ;;
|
||||
down) cmd_down ;;
|
||||
deep-reset) cmd_deep_reset ;;
|
||||
*) echo "usage: $0 {test|test-ready|up|run|prereqs|status|down|deep-reset}" >&2 ; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a qualified release and advance its matching channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.release_qualification import (
|
||||
build_release_pointer,
|
||||
canonical_pointer,
|
||||
publish_qualification,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--workflow-run", required=True)
|
||||
parser.add_argument("--qualified-at", required=True)
|
||||
parser.add_argument("--output", type=Path, default=Path("release.json"))
|
||||
parser.add_argument("--allow-rollback", action="store_true")
|
||||
args = parser.parse_args()
|
||||
pointer = build_release_pointer(
|
||||
tag=args.tag,
|
||||
source_commit=args.source_commit,
|
||||
workflow_run=args.workflow_run,
|
||||
qualified_at=args.qualified_at,
|
||||
)
|
||||
args.output.write_bytes(canonical_pointer(pointer))
|
||||
release_url, channel_url = publish_qualification(
|
||||
pointer, allow_rollback=args.allow_rollback)
|
||||
print(json.dumps({
|
||||
"release": release_url,
|
||||
"channel": channel_url,
|
||||
}, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,6 +10,8 @@ Kept in sync with ``bot_bottle.resources.BUNDLED_RESOURCES`` — the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
@@ -44,6 +46,18 @@ class _BundleResources(build_py):
|
||||
dst = pkg_resources / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
manifest_dest = Path(self.build_lib) / "bot_bottle" / "release-manifest.json"
|
||||
manifest_input = os.environ.get("BOT_BOTTLE_RELEASE_MANIFEST", "").strip()
|
||||
if manifest_input:
|
||||
shutil.copy2(manifest_input, manifest_dest)
|
||||
else:
|
||||
# Wheels built ad hoc retain the contributor/install.sh local-build
|
||||
# behavior. The release workflow always replaces this marker with
|
||||
# validated immutable artifact identities.
|
||||
manifest_dest.write_text(
|
||||
json.dumps({"schema": 1, "development": True}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
setup(cmdclass={"build_py": _BundleResources})
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
|
||||
```
|
||||
4. Skip guards live in `tests._backend` and gate on the backend's own
|
||||
readiness check, `bot_bottle.backend.has_backend` — the same probe
|
||||
behind `./cli.py backend status`:
|
||||
behind `bot-bottle backend status`:
|
||||
- Backend-agnostic tests (go through `get_bottle_backend()`) decorate
|
||||
the class with `@skip_unless_selected_backend_available()` — the test
|
||||
runs against whichever backend `BOT_BOTTLE_BACKEND` selects and skips
|
||||
@@ -105,7 +105,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
|
||||
`backend.docker.*`, …) decorate with `@skip_unless_backend("docker")`
|
||||
so they no-op under a run targeting a different backend.
|
||||
|
||||
Each CI integration job runs `./cli.py backend status --backend=<name>`
|
||||
Each CI integration job runs `bot-bottle backend status --backend=<name>`
|
||||
as a preflight, which prints a clear per-check summary and exits non-zero
|
||||
when the backend is missing — so absent infrastructure fails the job
|
||||
instead of hiding among per-test `unittest.skip` lines.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Each integration test targets the backend named by ``BOT_BOTTLE_BACKEND``
|
||||
(default ``docker``) and gates on that backend's full readiness check —
|
||||
``is_backend_ready()`` (equivalent to ``./cli.py backend status``), not just
|
||||
``is_backend_ready()`` (equivalent to ``bot-bottle backend status``), not just
|
||||
a binary-on-PATH probe. When the backend is not ready, diagnostic output is
|
||||
printed during test discovery so the operator sees a concrete reason for each
|
||||
skip.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the backend-aware skip guards in ``tests/_backend.py``.
|
||||
|
||||
The guards delegate their readiness check to
|
||||
``bot_bottle.backend.is_backend_ready`` (the probe behind ``./cli.py backend
|
||||
``bot_bottle.backend.is_backend_ready`` (the probe behind ``bot-bottle backend
|
||||
status``); here that probe is mocked so the unit job asserts the
|
||||
selection/skip logic without either backend present on the runner.
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit: the generic `backend` CLI command.
|
||||
|
||||
`./cli.py backend {setup,status} [--backend=NAME]` resolves a backend
|
||||
`bot-bottle backend {setup,status} [--backend=NAME]` resolves a backend
|
||||
and dispatches to its `setup()` / `status()` classmethods — no
|
||||
backend-specific commands. Also covers the docker backend's own
|
||||
setup/status logic (the firecracker path is exercised by
|
||||
|
||||
@@ -17,6 +17,7 @@ from bot_bottle.cli.commands import cleanup as cmd
|
||||
def _make_backend(empty: bool = True):
|
||||
backend = MagicMock()
|
||||
plan = MagicMock(empty=empty)
|
||||
plan.intersect.return_value = plan
|
||||
backend.prepare_cleanup.return_value = plan
|
||||
backend.cleanup = MagicMock()
|
||||
return backend, plan
|
||||
@@ -135,10 +136,12 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
docker.cleanup.assert_called_once_with(docker_plan)
|
||||
fc.cleanup.assert_not_called()
|
||||
|
||||
def test_executes_refreshed_plan_after_confirmation(self):
|
||||
def test_executes_only_displayed_resources_still_current(self):
|
||||
backend = MagicMock()
|
||||
preview = MagicMock(empty=False)
|
||||
refreshed = MagicMock(empty=False)
|
||||
approved = MagicMock(empty=False)
|
||||
preview.intersect.return_value = approved
|
||||
backend.prepare_cleanup.side_effect = [preview, refreshed]
|
||||
|
||||
with patch.object(
|
||||
@@ -152,7 +155,8 @@ class TestCmdCleanup(unittest.TestCase):
|
||||
):
|
||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||
|
||||
backend.cleanup.assert_called_once_with(refreshed)
|
||||
preview.intersect.assert_called_once_with(refreshed)
|
||||
backend.cleanup.assert_called_once_with(approved)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -44,6 +44,17 @@ class TestProjectNaming(unittest.TestCase):
|
||||
|
||||
|
||||
class TestComposeProjectListing(unittest.TestCase):
|
||||
def test_compose_ls_empty_when_docker_unusable(self):
|
||||
# Missing is the obvious case; present-but-not-executable raises
|
||||
# PermissionError instead, which must not escape as a crash.
|
||||
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "docker")):
|
||||
with self.subTest(exc=type(exc).__name__):
|
||||
with mock.patch(
|
||||
"bot_bottle.backend.docker.compose.subprocess.run",
|
||||
side_effect=exc,
|
||||
):
|
||||
self.assertEqual([], list_compose_projects())
|
||||
|
||||
def test_compose_ls_error_warns_by_default(self):
|
||||
with (
|
||||
mock.patch(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user