Compare commits

..

24 Commits

Author SHA1 Message Date
didericis a4413406df feat(smolmachines): run backend on Linux
lint / lint (push) Failing after 1m48s
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Successful in 15s
Port the smolmachines backend so BOT_BOTTLE_BACKEND=smolmachines
works on Linux (KVM), not just macOS:

- Preflight gates /dev/kvm presence + accessibility on Linux with
  actionable remediation (kvm module, kvm group).
- smolvm state-DB path is platform-derived (XDG on Linux).
- force_allowlist runs on both platforms and is fail-closed: it
  verifies the persisted TSI allowlist and dies rather than booting
  a VM whose egress confinement it can't confirm. Previously it
  no-oped on Linux, failing OPEN.
- allocate() does per-bottle 127.0.0.<N> scoping on Linux too (no
  ifconfig needed — all of 127/8 is already loopback); only
  ensure_pool's lo0 aliasing stays macOS-only.
- README documents Linux + NixOS host setup.

Linux/KVM integration (the sandbox-escape acceptance gate) is
pending verification on a NixOS host; unit tests cover the new
platform branches.

Issue: #283

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
2026-06-25 16:49:04 -04:00
didericis 49c75dc852 docs(prd): add PRD for smolmachines backend on Linux
Design for porting the smolmachines backend off macOS-only: KVM
preflight, platform-aware smolvm state-DB path, fail-closed TSI
allowlist enforcement, and per-bottle loopback scoping on Linux.
NixOS is the primary validation target.

Issue: #283

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
2026-06-25 16:49:04 -04:00
Quality Badge Bot d7fbe8e8a9 chore: update quality badges
- Pylint: 9.93/10
- Pyright: 0 errors
- Coverage: 79%

[skip ci]
2026-06-25 20:11:29 +00:00
didericis 50f5b3aa7f ci(badges): add coverage percentage to quality badges
test / integration (pull_request) Successful in 16s
test / unit (pull_request) Successful in 43s
test / unit (push) Successful in 44s
lint / lint (push) Successful in 1m49s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m53s
The update-badges workflow only refreshed pylint and pyright. Add a
coverage step that runs the unit suite under coverage.py, extracts the
TOTAL percentage, and updates a new coverage badge in the README.
Also trigger the workflow on .coveragerc changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
2026-06-25 15:13:44 -04:00
didericis-claude 45a096413f fix: add type annotations to __exit__ context manager (pyright)
lint / lint (push) Successful in 1m47s
test / unit (pull_request) Successful in 43s
test / integration (pull_request) Successful in 16s
2026-06-25 15:03:06 -04:00
didericis c6479d62e4 test: add coverage for git gate and supervise server 2026-06-25 15:03:06 -04:00
didericis d0cad3a559 chore: ignore coverage data 2026-06-25 15:03:06 -04:00
didericis c2ddac1be5 test: fix integration coverage failures 2026-06-25 15:03:06 -04:00
didericis 446414144e test: tune coverage exclusions 2026-06-25 15:03:06 -04:00
didericis 8188d6304e ci: add coverage.py reporting 2026-06-25 15:03:06 -04:00
github-actions[bot] 9f7c067e85 ci(prd): assign sequential numbers to new PRDs 2026-06-25 11:42:07 +00:00
didericis-codex 90e84a52e6 fix: remove unused supervise import for pyright
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m48s
Update Quality Badges / update-badges (push) Failing after 1m18s
prd-number / assign-numbers (push) Successful in 23s
test / unit (push) Successful in 33s
test / integration (push) Successful in 17s
2026-06-25 05:45:55 -04:00
didericis-claude 75755a472f refactor: drop redundant single-parent fast path in _resolve_one_bottle
lint / lint (push) Failing after 1m50s
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 18s
_fold_parents with one name returns after the first resolve; the
single-element branch was a verbatim copy of the general path.
2026-06-25 05:10:03 -04:00
didericis-claude 2f3dc57fa9 fix: resolve pyright reportUnnecessaryIsInstance in _resolve_one_bottle
Validate list entries against object-typed raw_list before narrowing to
list[str], so the isinstance(pname, str) check is not redundant.
2026-06-25 05:10:03 -04:00
didericis-claude 302920e290 feat: support multiple parents in bottle extends:
Allow extends: to accept a list of bottle names in addition to a plain
string. Parents are resolved independently and folded left-to-right
into a single combined parent before the child is merged on top, so
orthogonal concerns (base env, networking, agent provider) can live in
separate bottles without forcing a linear chain.

Merge rules for the parent fold: env dict-merge with later winning on
collision; git-gate.user per-field overlay; git-gate.repos union by
name with later winning per-field on same name; egress.routes
concatenated; all scalar fields (supervise, agent_provider, egress.log)
use last-wins. The existing child-wins-over-all-parents rule is
unchanged. Cycle detection, diamond deduplication, and missing/invalid
parent errors all work across multi-parent graphs.

Closes #268
2026-06-25 05:10:03 -04:00
Quality Badge Bot ca1b4afaea chore: update quality badges
- Pylint: 9.93/10
- Pyright: 1 errors

[skip ci]
2026-06-25 09:06:44 +00:00
didericis-codex d2072b13be feat!: remove capability apply
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 18s
lint / lint (push) Failing after 1m53s
test / unit (push) Successful in 40s
test / integration (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 1m37s
2026-06-25 08:58:28 +00:00
didericis-codex 36c5b7025b feat: add ripgrep to agent images
lint / lint (push) Successful in 1m48s
2026-06-25 04:32:53 -04:00
didericis-claude 515a95a79d fix: escape quotes/newlines in YAML and gitconfig emitters
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m48s
test / unit (push) Successful in 32s
test / integration (push) Successful in 16s
Update Quality Badges / update-badges (push) Successful in 1m18s
Closes #258.

`egress_render_routes` and `_render_match_entry` now pass all manifest
strings (host, auth_scheme, token_env, path/header values) through
`_yaml_str_escape` before interpolating into double-quoted YAML scalars,
preventing stray `"` or newlines from corrupting routes.yaml.

`git_gate_render_gitconfig` now calls `_gitconfig_validate_value` on
each Upstream value (and the derived alias) before writing the
`insteadOf` line, rejecting any value containing a newline that would
inject arbitrary gitconfig keys.
2026-06-25 04:23:13 -04:00
didericis-claude 0bace7615a refactor: rename GIT_GATE_DAEMON_TIMEOUT_SECS to GIT_GATE_TIMEOUT_SECS
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m48s
test / unit (push) Successful in 35s
test / integration (push) Successful in 17s
Update Quality Badges / update-badges (push) Successful in 1m20s
The constant now covers the daemon path, the HTTP backend access-hook,
and the git http-backend CGI subprocess, so 'daemon' in the name was
too narrow. Updated the comment to list all three current uses.
2026-06-25 04:12:43 -04:00
didericis-claude c0d3f16519 refactor: import GIT_GATE_DAEMON_TIMEOUT_SECS instead of duplicating the value 2026-06-25 04:12:43 -04:00
didericis-claude 508c537deb fix: add explicit timeouts to subprocess and HTTP calls in git-gate paths
Closes #255. Without timeouts, a hung upstream during the access-hook
or git http-backend CGI call (git_http_backend.py) and a stalled Gitea
API during deploy-key provisioning (contrib/gitea/deploy_key_provisioner.py)
could wedge a sidecar indefinitely. Adds GIT_HTTP_BACKEND_TIMEOUT_SECS
(30s) to both subprocess.run calls in the HTTP backend, mirroring the
existing GIT_GATE_DAEMON_TIMEOUT_SECS on the daemon path. Adds
_API_TIMEOUT_SECS (30s) and _KEYGEN_TIMEOUT_SECS (10s) to the Gitea
provisioner's urlopen and ssh-keygen calls. Tests verify the timeout
values are forwarded in all four call sites.
2026-06-25 04:12:43 -04:00
didericis-claude d99dba037c feat(supervise): typed RPC error taxonomy for dispatch
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 17s
lint / lint (push) Successful in 1m48s
test / unit (push) Successful in 32s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m20s
Introduce _RpcClientError and _RpcInternalError as distinct subclasses
of _RpcError so the dispatcher can handle bad requests and server-side
faults differently — returning client errors verbatim and logging
internal faults with their cause before replying ERR_INTERNAL.

Wrap write_proposal and archive_proposal IO with _RpcInternalError
so OS failures surface through the typed path instead of the bare
Exception fallback. All existing raise _RpcError(...) call sites
converted to _RpcClientError.

Closes #253
2026-06-25 04:02:39 -04:00
didericis-claude 9a878bd885 fix: guard CGI Status-line parse in _write_cgi_response
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 16s
lint / lint (push) Successful in 1m47s
test / unit (push) Successful in 32s
test / integration (push) Successful in 16s
Update Quality Badges / update-badges (push) Successful in 1m19s
An empty or non-numeric Status: header from git http-backend raised
ValueError/IndexError that escaped the handler thread. Wrap the parse
in a try/except and fall back to HTTP 500 instead.

Closes #254
2026-06-25 03:47:05 -04:00
49 changed files with 1765 additions and 637 deletions
+9
View File
@@ -0,0 +1,9 @@
[run]
branch = True
source = .
[report]
omit =
bot_bottle/egress_addon.py
bot_bottle/cli/tui.py
tests/*
+7 -1
View File
@@ -39,8 +39,14 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install dev requirements
run: python3 -m pip install -r requirements-dev.txt
- name: Run unit tests - name: Run unit tests
run: python3 -m unittest discover -t . -s tests/unit -v run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
- name: Report unit coverage
run: python3 -m coverage report -m
integration: integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+15 -2
View File
@@ -8,6 +8,7 @@ on:
- '**.py' - '**.py'
- '.pylintrc' - '.pylintrc'
- 'pyrightconfig.json' - 'pyrightconfig.json'
- '.coveragerc'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -45,10 +46,19 @@ jobs:
echo "errors=$ERRORS" >> $GITHUB_OUTPUT echo "errors=$ERRORS" >> $GITHUB_OUTPUT
echo "Pyright errors: $ERRORS" echo "Pyright errors: $ERRORS"
- name: Run coverage and extract percentage
id: coverage
run: |
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Coverage: $PERCENT%"
- name: Update badges in README - name: Update badges in README
run: | run: |
PYLINT_SCORE="${{ steps.pylint.outputs.score }}" PYLINT_SCORE="${{ steps.pylint.outputs.score }}"
PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}" PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}"
COVERAGE_PERCENT="${{ steps.coverage.outputs.percent }}"
PYLINT_SCORE_ENCODED=$(echo "$PYLINT_SCORE" | sed 's|/|%2F|g') PYLINT_SCORE_ENCODED=$(echo "$PYLINT_SCORE" | sed 's|/|%2F|g')
@@ -58,9 +68,12 @@ jobs:
if [ -n "$PYRIGHT_ERRORS" ]; then if [ -n "$PYRIGHT_ERRORS" ]; then
sed -i "s|/badge/pyright-[^)]*|/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen|" README.md sed -i "s|/badge/pyright-[^)]*|/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen|" README.md
fi fi
if [ -n "$COVERAGE_PERCENT" ]; then
sed -i "s|/badge/coverage-[^)]*|/badge/coverage-${COVERAGE_PERCENT}%25-brightgreen|" README.md
fi
echo "Updated badges:" echo "Updated badges:"
grep -E "pylint|pyright" README.md | head -2 grep -E "pylint|pyright|coverage" README.md | head -3
- name: Commit and push badge updates - name: Commit and push badge updates
run: | run: |
@@ -73,7 +86,7 @@ jobs:
else else
echo "Badge changes detected, committing..." echo "Badge changes detected, committing..."
git add README.md git add README.md
MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n\n'"[skip ci]" MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n'"- Coverage: ${{ steps.coverage.outputs.percent }}%"$'\n\n'"[skip ci]"
git commit -m "$MSG" git commit -m "$MSG"
git push git push
fi fi
+1
View File
@@ -22,3 +22,4 @@ venv/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.coverage
+19 -2
View File
@@ -7,6 +7,7 @@
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint) [![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint)
[![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright) [![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright)
[![coverage](https://img.shields.io/badge/coverage-79%25-brightgreen)](https://coverage.readthedocs.io/)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
@@ -25,7 +26,7 @@
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. - **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. - **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. Runs on macOS (Hypervisor.framework) and Linux (KVM, `/dev/kvm`).
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. - **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
## Architecture ## Architecture
@@ -71,10 +72,26 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
## Quickstart ## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus `smolvm` (macOS or Linux). The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
### smolmachines on Linux
The smolmachines backend runs on Linux as well as macOS. On Linux, `smolvm`/libkrun use KVM, so the host 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.
- **`smolvm`** on `PATH`: `curl -sSL https://smolmachines.com/install.sh | sh`.
- **Docker** for the sidecar bundle and image build, same as macOS.
Per-bottle isolation works the same as macOS without any `ifconfig`/sudo step — all of `127.0.0.0/8` is already loopback on Linux, so each bottle's sidecar bundle is published on its own `127.0.0.<N>` and TSI's allowlist is scoped to that `/32`.
```sh
BOT_BOTTLE_BACKEND=smolmachines ./cli.py 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. If you run bottles from a Gitea Actions runner, use a `host`-label runner so Docker, `smolvm`, and `/dev/kvm` are all reachable from the job. `smolvm` isn't in nixpkgs — install the release binary (pin the version) and put it on the runner's `PATH`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
@@ -1,211 +0,0 @@
"""capability_apply — host-side orchestrator for capability-block
remediation (PRD 0016).
On approval of a capability-block proposal, the dashboard calls
apply_capability_change(slug, new_dockerfile) which:
1. Snapshots the agent's transcript dir to
~/.bot-bottle/state/<slug>/transcript/ (best-effort).
2. Pushes the agent's working tree via `git push` (best-effort —
no upstream / no commits / no git repo all skip with a log).
3. Writes the new Dockerfile to
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
state). The next `cli.py start <agent>` picks it up.
4. Force-removes the agent container + all sidecars + the
per-bottle networks. Idempotent — missing resources are not
errors.
Returns (before, after) Dockerfile contents so the dashboard can
record / render the diff. (capability-block has no audit log per
PRD 0013 — the per-bottle Dockerfile state is its own record.)
This is "fire-and-forget" from the agent's perspective: by the time
the dashboard writes the response file the supervise sidecar is
gone, so the agent's tool call connection drops without ever
receiving the response. The replacement agent (next manual
`cli.py start`) sees the new Dockerfile and starts from there.
v1 does not auto-relaunch — see PRD 0016's capability-block return
semantics open question.
"""
from __future__ import annotations
import shutil
import subprocess
from ...agent_provider import get_provider
from ...log import info, warn
from ...bottle_state import (
mark_preserved,
per_bottle_dockerfile,
transcript_snapshot_dir,
write_per_bottle_dockerfile,
)
from .sidecar_bundle import sidecar_bundle_container_name
# Agent home inside the container (per the repo Dockerfile's
# `USER node` + `WORKDIR /home/node`). Used to locate the transcript
# dir + the workspace dir for git push.
_AGENT_HOME_IN_CONTAINER = "/home/node"
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
# Per-bottle resource name patterns (mirroring prepare.py).
def _agent_container_name(slug: str) -> str:
return f"bot-bottle-{slug}"
def _per_bottle_container_names(slug: str) -> list[str]:
"""All container names that belong to this bottle. Missing
containers are silently skipped by the teardown helper, so it's
fine to include names that don't exist for a given bottle."""
return [
_agent_container_name(slug),
sidecar_bundle_container_name(slug),
]
def _per_bottle_network_names(slug: str) -> list[str]:
return [
f"bot-bottle-net-{slug}",
f"bot-bottle-egress-{slug}",
]
class CapabilityApplyError(RuntimeError):
"""Raised when the apply fails in a way that should keep the
proposal pending (so the operator can retry). Best-effort
failures (transcript snapshot, git push) do not raise — they
just log and proceed."""
# --- Public helpers --------------------------------------------------------
def fetch_current_dockerfile(slug: str) -> str:
"""Return the Dockerfile content the next `cli.py start <agent>`
would use for this bottle. If a per-bottle override exists, that
one; otherwise the repo's Dockerfile.
Used by the operator-edit verb to show the current source of
truth, and by apply_capability_change for the before-diff."""
override = per_bottle_dockerfile(slug)
if override is not None:
return override
repo_dockerfile = get_provider("claude").dockerfile
if repo_dockerfile.is_file():
return repo_dockerfile.read_text()
raise CapabilityApplyError(
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
f"{repo_dockerfile}"
)
def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
"""End-to-end capability-block remediation. See module docstring
for the sequence. Returns (before, after) Dockerfile content."""
if not new_dockerfile.strip():
raise CapabilityApplyError("proposed Dockerfile is empty")
before = fetch_current_dockerfile(slug)
snapshot_transcript(slug)
_push_working_tree(slug)
write_per_bottle_dockerfile(slug, new_dockerfile)
# Set the preserve marker BEFORE teardown so cli.py's session-end
# cleanup sees it and keeps the state dir intact for the
# operator's `cli.py resume <identity>`. Without the marker the
# state dir would be deleted as part of normal session end.
mark_preserved(slug)
_teardown_bottle(slug)
return before, new_dockerfile
# --- Internals -------------------------------------------------------------
def snapshot_transcript(slug: str) -> None:
"""`docker cp` /home/node/.claude out of the agent container into
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
container, missing dir, or cp error all log a warning and return.
The transcript is what `claude --resume` reads to pick up where
the agent left off.
Called from two places:
- capability-apply, before tearing the bottle down.
- cli.py's session-end path, before the launch context closes,
so a crash or normal exit also leaves a transcript on disk
(deleted along with the state dir on clean exit, kept on
crash or capability-block per the preserve marker)."""
container = _agent_container_name(slug)
dest = transcript_snapshot_dir(slug)
if dest.exists():
# Remove any prior snapshot so the new one is a clean copy.
shutil.rmtree(dest, ignore_errors=True)
dest.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["docker", "cp", f"{container}:{_AGENT_TRANSCRIPT_IN_CONTAINER}", str(dest)],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
warn(
f"transcript snapshot skipped "
f"({(r.stderr or '').strip() or 'no transcript dir in container?'})"
)
return
info(f"transcript snapshotted to {dest}")
def _push_working_tree(slug: str) -> None:
"""`docker exec <agent> git push` from /home/node/workspace.
Best-effort: not-a-git-repo, no upstream, nothing-to-push, no
network all log a warning and return. The replacement bottle
will pick up whatever's actually upstream."""
container = _agent_container_name(slug)
r = subprocess.run(
[
"docker", "exec", container, "sh", "-c",
f"cd {_AGENT_WORKSPACE_IN_CONTAINER} && "
f"git rev-parse --is-inside-work-tree >/dev/null 2>&1 && "
f"git push origin HEAD 2>&1 || true",
],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
warn(
f"capability-apply: git push skipped "
f"({(r.stderr or '').strip() or 'docker exec failed'})"
)
return
output = (r.stdout or "").strip()
if output:
info(f"capability-apply: git push: {output}")
else:
info("capability-apply: git push ran (no output — likely not a git workspace)")
def _teardown_bottle(slug: str) -> None:
"""Force-remove all per-bottle docker resources. Idempotent —
`docker rm -f` / `docker network rm` silently ignore missing
names, so this can be called even mid-rebuild."""
info(f"capability-apply: tearing down bottle {slug}")
for name in _per_bottle_container_names(slug):
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
for net in _per_bottle_network_names(slug):
subprocess.run(
["docker", "network", "rm", net],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
__all__ = [
"CapabilityApplyError",
"apply_capability_change",
"fetch_current_dockerfile",
"snapshot_transcript",
]
-10
View File
@@ -34,7 +34,6 @@ from ...egress import (
from ...git_gate import GIT_GATE_HOSTNAME from ...git_gate import GIT_GATE_HOSTNAME
from ...log import die, warn from ...log import die, warn
from ...supervise import ( from ...supervise import (
CURRENT_CONFIG_DIR_IN_AGENT,
QUEUE_DIR_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER,
SUPERVISE_HOSTNAME, SUPERVISE_HOSTNAME,
SUPERVISE_PORT, SUPERVISE_PORT,
@@ -233,15 +232,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
if plan.use_runsc: if plan.use_runsc:
service["runtime"] = "runsc" service["runtime"] = "runsc"
volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None:
volumes.append(_bind(
plan.supervise_plan.current_config_dir,
CURRENT_CONFIG_DIR_IN_AGENT,
))
if volumes:
service["volumes"] = volumes
# The init supervisor inside the bundle owns intra-bundle # The init supervisor inside the bundle owns intra-bundle
# daemon ordering, so the agent only waits for the bundle # daemon ordering, so the agent only waits for the bundle
# container itself. # container itself.
+23 -15
View File
@@ -141,10 +141,12 @@ def _allocate_resources(
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Reserve a loopback alias and create the per-bottle docker bridge. """Reserve a loopback alias and create the per-bottle docker bridge.
macOS only routes 127.0.0.1 by default; the per-bottle alias The per-bottle alias scopes TSI's allowlist to this bottle's
scopes TSI's allowlist to this bottle's published ports so the published ports so the agent can't reach other bottles' or host
agent can't reach other bottles' or host services' ports on services' ports on loopback. On macOS `ensure_pool` first
loopback. No-op on Linux.""" sudo-aliases the pool on `lo0`; on Linux that's a no-op since
all of 127.0.0.0/8 is already loopback, but the per-bottle
allocation runs on both."""
_loopback.ensure_pool() _loopback.ensure_pool()
loopback_ip = _loopback.allocate(plan.slug) loopback_ip = _loopback.allocate(plan.slug)
network = _bundle.bundle_network_name(plan.slug) network = _bundle.bundle_network_name(plan.slug)
@@ -190,9 +192,11 @@ def _discover_urls(
return the plan with URLs + guest_env stamped in. return the plan with URLs + guest_env stamped in.
Docker container IPs (192.168.x.x in the daemon's bridge) Docker container IPs (192.168.x.x in the daemon's bridge)
aren't reachable from the smolvm guest on macOS — TSI uses aren't reachable from the smolvm guest — TSI proxies the
macOS networking, and macOS sees the daemon's bridge via the guest's connects through the host, and the host reaches the
published-port loopback forward only. bundle only via its published-port loopback forward (the
daemon's bridge isn't on the TSI allowlist). The agent dials
the published port on the per-bottle loopback alias.
NO_PROXY includes the per-bottle loopback alias so the NO_PROXY includes the per-bottle loopback alias so the
supervise + git-gate URLs bypass HTTPS_PROXY.""" supervise + git-gate URLs bypass HTTPS_PROXY."""
@@ -252,10 +256,11 @@ def _launch_vm(
"""Create, patch, and start the smolvm VM; register teardown. """Create, patch, and start the smolvm VM; register teardown.
--allow-cidr is the per-bottle loopback alias so the guest can --allow-cidr is the per-bottle loopback alias so the guest can
only reach this bottle's bundle ports. force_allowlist patches only reach this bottle's bundle ports. force_allowlist then
smolvm 0.8.0's silent-drop of --allow-cidr when combined with confirms the allowlist persisted (patching smolvm 0.8.0's
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from silent-drop of --allow-cidr when combined with --from) and
and --smolfile mutually exclusive.""" fails closed if it can't. Smolfile isn't usable here — smolvm
0.8.0 makes --from and --smolfile mutually exclusive."""
_smolvm.machine_create( _smolvm.machine_create(
plan.machine_name, plan.machine_name,
from_path=agent_from_path, from_path=agent_from_path,
@@ -263,9 +268,10 @@ def _launch_vm(
env=plan.guest_env, env=plan.guest_env,
) )
stack.callback(_smolvm.machine_delete, plan.machine_name) stack.callback(_smolvm.machine_delete, plan.machine_name)
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped # Confirm the booted VM's TSI allowlist will actually enforce the
# when combined with `--from`. Patch the persisted state DB # /32 before start (smolvm 0.8.0 silently drops `--allow-cidr`
# before start so the booted VM's TSI actually enforces. # with `--from`, so the persisted state DB is patched if needed).
# Fails closed if enforcement can't be confirmed.
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"]) _loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
_smolvm.machine_start(plan.machine_name) _smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name)
@@ -275,7 +281,9 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
"""Repair filesystem ownership and wait for exec channel readiness. """Repair filesystem ownership and wait for exec channel readiness.
Ownership repair: smolvm's pack process remaps files to the host Ownership repair: smolvm's pack process remaps files to the host
invoker's uid (501 on macOS). /home/node must be node:node so invoker's uid (e.g. 501 on macOS, 1000 on Linux). The chowns use
names not numbers so they're correct on either. /home/node must
be node:node so
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
mode 1777 so non-root processes can create per-uid scratch dirs. mode 1777 so non-root processes can create per-uid scratch dirs.
All folded into one sh -c to avoid back-to-back exec calls All folded into one sh -c to avoid back-to-back exec calls
@@ -33,10 +33,13 @@ sudo-add the missing pool on first use per boot — the aliases
persist on `lo0` until reboot, so subsequent launches don't persist on `lo0` until reboot, so subsequent launches don't
prompt. prompt.
Linux native daemons share the host's network namespace; the On Linux the whole `127.0.0.0/8` is already routed to `lo`, so
whole `127.0.0.0/8` is reachable by default and aliases are docker can publish a bundle's ports directly on `127.0.0.<N>`
unnecessary. The pool logic detects native-Linux and skips sudo with no `ifconfig`/sudo step. `ensure_pool` is therefore a no-op
entirely; the DB patch is also gated on macOS. on Linux, but per-bottle alias *allocation* and the TSI allowlist
DB patch run on both platforms — the isolation property is
identical, it's just cheaper to set up on Linux. The state-DB
path differs per platform (see `_smolvm_db_path`).
Allocation is coordinated by inspecting running bundle Allocation is coordinated by inspecting running bundle
containers' published host IPs — each bottle's bundle owns the containers' published host IPs — each bottle's bundle owns the
@@ -47,6 +50,7 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os
import platform import platform
import re import re
import sqlite3 import sqlite3
@@ -57,20 +61,34 @@ from typing import Iterable
from ...log import die, info from ...log import die, info
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms` def _smolvm_db_path() -> Path:
# table holds one JSON BLOB per machine. The Linux path is """smolvm's persistent VM state — a SQLite DB whose `vms` table
# different, but smolmachines is macOS-only in v1 (PRD 0023) so holds one JSON BLOB per machine. macOS stores it under
# we hard-code this. If the file moves under us we'll see a `Application Support`; Linux follows the XDG base-dir spec
# clear FileNotFoundError; not worth defensive cross-platform (`$XDG_DATA_HOME`, default `~/.local/share`).
# detection until the backend actually needs Linux.
_SMOLVM_DB_PATH = ( NOTE: the Linux location is inferred from smolvm's documented
Path.home() `~/.local/share` install layout and must be confirmed against a
/ "Library" real Linux smolvm install. If it's wrong, `force_allowlist`'s
/ "Application Support" fail-closed check turns it into a clear launch-time error rather
/ "smolvm" than a silent escape."""
/ "server" if platform.system() == "Darwin":
/ "smolvm.db" return (
) Path.home()
/ "Library"
/ "Application Support"
/ "smolvm"
/ "server"
/ "smolvm.db"
)
xdg_data = os.environ.get("XDG_DATA_HOME")
base = Path(xdg_data) if xdg_data else Path.home() / ".local" / "share"
return base / "smolvm" / "server" / "smolvm.db"
# Resolved once at import: the host platform doesn't change within a
# process. Tests patch this attribute directly.
_SMOLVM_DB_PATH = _smolvm_db_path()
# Sixteen aliases by default. Tunable for hosts that want more # Sixteen aliases by default. Tunable for hosts that want more
@@ -131,51 +149,74 @@ def ensure_pool() -> None:
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None: def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
"""Patch smolvm's persistent VM-state DB to set the machine's """Ensure the machine's persisted TSI allowlist equals
`allowed_cidrs` to the given list. Workaround for smolvm `allowed_cidrs`, failing **closed** if that can't be confirmed.
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
Must run AFTER `smolvm machine create` (the row has to Runs on both macOS and Linux. It exists because smolvm 0.8.0
exist) and BEFORE `smolvm machine start` (smolvm reads the silently drops `--allow-cidr` when combined with `--from`, so
row on start; in-flight VMs don't pick up changes). Once the allowlist has to be written into smolvm's persistent state
smolvm honors the CLI flag upstream this whole function is DB before `machine start`. Rather than assume the flag was
redundant — flag-respecting create + remove this call from dropped, we read the persisted row and only patch when it
launch. doesn't already match — so a newer smolvm that honors the flag
is left untouched.
No-op on non-macOS — the DB path differs and the Linux Must run AFTER `smolvm machine create` (the row has to exist)
smolmachines code path isn't exercised in v1.""" and BEFORE `smolvm machine start` (smolvm reads the row on
if not _is_macos(): start; in-flight VMs don't pick up changes).
return
Fail-closed: if the state DB is missing, the row is missing, or
the allowlist still doesn't match after patching, we `die()`
rather than boot a VM whose egress confinement we can't verify
— an unconfirmed allowlist is a sandbox-escape risk (the agent
VM could reach all of host loopback)."""
want = list(allowed_cidrs)
if not _SMOLVM_DB_PATH.is_file(): if not _SMOLVM_DB_PATH.is_file():
die( die(
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. " f"smolvm state DB not found at {_SMOLVM_DB_PATH}; cannot "
f"smolvm 0.8.0 expected? `smolvm --version` to check." f"confirm the TSI allowlist is enforced. Refusing to launch "
f"(fail-closed). Check `smolvm --version` and the DB "
f"location for your platform."
) )
con = sqlite3.connect(str(_SMOLVM_DB_PATH)) con = sqlite3.connect(str(_SMOLVM_DB_PATH))
try: try:
cur = con.cursor() cfg = _read_machine_cfg(con, machine_name)
row = cur.execute( if cfg.get("allowed_cidrs") != want:
"SELECT data FROM vms WHERE name = ?", (machine_name,), cfg["allowed_cidrs"] = want
).fetchone() # Write as BLOB (the column type smolvm uses) — passing a
if row is None: # plain str makes sqlite store it as Text and smolvm then
die( # fails to read it.
f"smolvm DB has no row for machine {machine_name!r}" con.execute(
f"machine_create must run before force_allowlist." "UPDATE vms SET data = ? WHERE name = ?",
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
)
con.commit()
cfg = _read_machine_cfg(con, machine_name)
if cfg.get("allowed_cidrs") != want:
die(
f"could not enforce TSI allowlist {want!r} for machine "
f"{machine_name!r} (persisted value is "
f"{cfg.get('allowed_cidrs')!r}). Refusing to launch "
f"(fail-closed)."
) )
cfg = json.loads(row[0])
cfg["allowed_cidrs"] = list(allowed_cidrs)
# Write as BLOB (the column type smolvm uses) — passing a
# plain str makes sqlite store it as Text and smolvm then
# fails to read it.
cur.execute(
"UPDATE vms SET data = ? WHERE name = ?",
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
)
con.commit()
finally: finally:
con.close() con.close()
def _read_machine_cfg(con: sqlite3.Connection, machine_name: str) -> dict[str, object]:
"""Read + JSON-decode a machine's `data` BLOB from the smolvm
state DB. Dies (fail-closed) if the row is missing — the caller
can't confirm enforcement without it."""
row = con.execute(
"SELECT data FROM vms WHERE name = ?", (machine_name,),
).fetchone()
if row is None:
die(
f"smolvm DB has no row for machine {machine_name!r}"
f"machine_create must run before force_allowlist."
)
return json.loads(row[0])
def allocate(_slug: str) -> str: def allocate(_slug: str) -> str:
"""Pick the lowest-numbered alias from the pool not already """Pick the lowest-numbered alias from the pool not already
in use by a running smolmachines bundle. Bails when the pool in use by a running smolmachines bundle. Bails when the pool
@@ -184,16 +225,17 @@ def allocate(_slug: str) -> str:
used (no on-disk reservation, allocation is purely used (no on-disk reservation, allocation is purely
docker-state-driven). docker-state-driven).
On non-macOS the whole `127.0.0.0/8` is loopback by default; Runs on both platforms: the allocation logic (docker-state
`127.0.0.1` is fine to share and we skip the alias dance. inspection + the file lock) is platform-independent. macOS
This still returns a deterministic address so launch.py's needs `ensure_pool` to have aliased the addresses on `lo0`
callers don't have to branch on platform. first; on Linux all of `127.0.0.0/8` is already loopback, so
docker can publish on the chosen `127.0.0.<N>` with no setup.
Per-bottle scoping (so the agent can't reach other bottles' or
host services' loopback ports) therefore holds on both.
An exclusive file lock serialises concurrent calls so two An exclusive file lock serialises concurrent calls so two
simultaneous launches don't read the same docker state and simultaneous launches don't read the same docker state and
claim the same alias.""" claim the same alias."""
if not _is_macos():
return "127.0.0.1"
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) _ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf: with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
fcntl.flock(lf, fcntl.LOCK_EX) fcntl.flock(lf, fcntl.LOCK_EX)
+46 -14
View File
@@ -5,26 +5,58 @@ unit-tested without importing the docker subprocess paths."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import os
import platform
import shutil import shutil
from ...log import die from ...log import die
# libkrun's Linux backend drives the guest through KVM, so the host
# must expose `/dev/kvm` and the invoking user must be able to open
# it. macOS uses Hypervisor.framework and needs no device node.
_KVM_DEVICE = "/dev/kvm"
def smolmachines_preflight() -> None: def smolmachines_preflight() -> None:
"""Ensure `smolvm` is on PATH before the launch flow runs. """Ensure the host can run the smolmachines backend before the
Called from `_resolve_plan`; gives the operator a clear launch flow starts. Called from `_resolve_plan`; surfaces a
install pointer rather than a cryptic FileNotFoundError clear, actionable error instead of a cryptic `smolvm` failure
later. `gvproxy` is no longer required — see the PRD's design deep in launch.
pivot section."""
if shutil.which("smolvm") is not None: Checks `smolvm` is on PATH (both platforms) and, on Linux,
return that `/dev/kvm` exists and is accessible. `gvproxy` is no
die( longer required — see the PRD's design pivot section."""
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on " if shutil.which("smolvm") is None:
"PATH. Install with: " die(
"curl -sSL https://smolmachines.com/install.sh | sh. " "BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"To use the legacy Docker backend instead, set " "PATH. Install with: "
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker." "curl -sSL https://smolmachines.com/install.sh | sh. "
) "To use the legacy Docker backend instead, set "
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
)
if platform.system() == "Linux":
_preflight_kvm()
def _preflight_kvm() -> None:
"""Linux-only: libkrun needs `/dev/kvm`. Distinguish 'KVM not
enabled' from 'no permission' so the operator knows which to
fix."""
if not os.path.exists(_KVM_DEVICE):
die(
f"BOT_BOTTLE_BACKEND=smolmachines needs {_KVM_DEVICE} on "
"Linux but it is missing. Enable KVM: load the kvm-intel "
"or kvm-amd kernel module (and confirm virtualization is "
"enabled in BIOS/firmware). To use the legacy Docker "
"backend instead, set BOT_BOTTLE_BACKEND=docker."
)
if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK):
die(
f"{_KVM_DEVICE} exists but is not readable/writable by the "
"current user. Add your user to the `kvm` group "
"(`sudo usermod -aG kvm \"$USER\"`) and re-login, or run "
"with access to the device."
)
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]: def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
+10 -16
View File
@@ -1,8 +1,7 @@
"""Per-bottle persistent state (PRD 0016). """Per-bottle persistent state.
Holds the per-bottle Dockerfile override that capability-block Holds optional per-bottle Dockerfile overrides, the transcript snapshot
remediation writes, the transcript snapshot the state-preservation the state-preservation helper saves before teardown, and the launch metadata that lets
helper saves before teardown, and the launch metadata that lets
`cli.py resume <identity>` reconstruct a bottle's spec. State `cli.py resume <identity>` reconstruct a bottle's spec. State
lives at: lives at:
@@ -61,7 +60,7 @@ _METADATA_NAME = "metadata.json"
_LIVE_CONFIG_SUBDIR = "live-config" _LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml" LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
# Empty marker file. capability_apply writes it before teardown so # Empty marker file. Session preservation writes it before teardown so
# cli.py's session-end cleanup knows to preserve the state dir for # cli.py's session-end cleanup knows to preserve the state dir for
# `cli.py resume <identity>`. Absent = clean up. # `cli.py resume <identity>`. Absent = clean up.
_PRESERVE_MARKER = ".preserve" _PRESERVE_MARKER = ".preserve"
@@ -164,8 +163,7 @@ def per_bottle_dockerfile_path(identity: str) -> Path:
def per_bottle_dockerfile(identity: str) -> str | None: def per_bottle_dockerfile(identity: str) -> str | None:
"""Return the per-bottle Dockerfile content if present, else """Return the per-bottle Dockerfile content if present, else
None. None means: use the repo's Dockerfile (the original None. None means: use the provider or manifest Dockerfile."""
pre-capability-block behavior)."""
p = per_bottle_dockerfile_path(identity) p = per_bottle_dockerfile_path(identity)
if p.is_file(): if p.is_file():
return p.read_text() return p.read_text()
@@ -249,9 +247,7 @@ def write_live_config(
def transcript_snapshot_dir(identity: str) -> Path: def transcript_snapshot_dir(identity: str) -> Path:
"""Where capability_apply stashes the agent's transcript before """Where agent session snapshots are kept for resume flows."""
teardown, so the next `cli.py start <agent>` can offer to
resume from it."""
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
@@ -278,8 +274,7 @@ def git_gate_state_dir(identity: str) -> Path:
def supervise_state_dir(identity: str) -> Path: def supervise_state_dir(identity: str) -> Path:
"""State subdir for the supervise sidecar's current-config dir """State subdir reserved for supervise sidecar bind-mount sources.
(bind-mounted into the agent at /etc/bot-bottle/current-config).
The queue dir is intentionally NOT under here — it lives at The queue dir is intentionally NOT under here — it lives at
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it ~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
survives state-dir cleanup.""" survives state-dir cleanup."""
@@ -301,9 +296,8 @@ def preserve_marker_path(identity: str) -> Path:
def mark_preserved(identity: str) -> Path: def mark_preserved(identity: str) -> Path:
"""Mark this bottle's state for preservation across session """Mark this bottle's state for preservation across session
teardown. Written by capability_apply.apply_capability_change so teardown so cli.py's session-end cleanup leaves the state dir
cli.py's session-end cleanup leaves the state dir intact for a intact for a subsequent `cli.py resume`."""
subsequent `cli.py resume`."""
path = preserve_marker_path(identity) path = preserve_marker_path(identity)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.touch() path.touch()
@@ -316,7 +310,7 @@ def is_preserved(identity: str) -> bool:
def clear_preserve_marker(identity: str) -> None: def clear_preserve_marker(identity: str) -> None:
"""Idempotent removal. Called at fresh launch (start or resume) """Idempotent removal. Called at fresh launch (start or resume)
so a marker left from a prior capability-block doesn't keep so a marker left from a prior preserved session doesn't keep
state alive past the next normal session-end.""" state alive past the next normal session-end."""
try: try:
preserve_marker_path(identity).unlink() preserve_marker_path(identity).unlink()
+2 -3
View File
@@ -13,9 +13,8 @@ dirs are shared layout, so docker is the single owner of that
bucket. bucket.
State dirs with `.preserve` are intentionally never touched they State dirs with `.preserve` are intentionally never touched they
hold capability-block rebuilds or crash snapshots the operator may hold preserved sessions the operator may want to `resume`. Manual
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>` `rm -rf ~/.bot-bottle/state/<identity>` is the path for those.
is the path for those.
""" """
from __future__ import annotations from __future__ import annotations
+4 -5
View File
@@ -4,13 +4,12 @@ Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
(agent_name, cwd, copy_cwd) the bottle was originally started with, (agent_name, cwd, copy_cwd) the bottle was originally started with,
then runs the same launch core as `start` but pinned to the then runs the same launch core as `start` but pinned to the
recorded identity so the new bottle picks up any per-bottle Dockerfile recorded identity so the new bottle picks up any per-bottle Dockerfile
(from capability-block apply) and transcript snapshot under the same override and transcript snapshot under the same state dir.
state dir.
Use case: an agent calls capability-block, the dashboard approves Use case: an interrupted or preserved bottle needs to be relaunched;
and tears down the bottle, the operator runs the operator runs
./cli.py resume <identity> ./cli.py resume <identity>
to bring up the replacement with the new capabilities baked in. to bring up the replacement from the recorded state.
""" """
from __future__ import annotations from __future__ import annotations
+2 -7
View File
@@ -31,7 +31,6 @@ from ..bottle_state import (
is_preserved, is_preserved,
mark_preserved, mark_preserved,
) )
# from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info from ..log import info
from ..manifest import ManifestIndex from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
@@ -257,12 +256,8 @@ def _launch_bottle(
) )
# While the container is still alive: always snapshot the # While the container is still alive: always snapshot the
# transcript and — if the agent exited non-zero — mark # transcript and — if the agent exited non-zero — mark
# the state for preservation. Capability-block already # the state for preservation. This picks up crashes /
# did both before triggering teardown from the dashboard; # Ctrl-Cs / OOM kills before cleanup removes the state dir.
# this picks up crashes / Ctrl-Cs / OOM kills the same
# way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered
# when the container is already gone.
if agent_provider_template == "claude": if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code) capture_claude_session_state(identity, exit_code)
return 0 return 0
+9 -36
View File
@@ -2,9 +2,8 @@
act on them (approve / modify / reject). act on them (approve / modify / reject).
Curses-based TUI; modify-then-approve shells out to $EDITOR. The Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handler wires to PRD 0016 (capability-block), which rebuilds Egress proposals are queued for operator review as full routes.yaml
the bottle Dockerfile. Egress proposals are queued for operator review updates.
as full routes.yaml updates.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,10 +21,6 @@ from pathlib import Path
from .. import supervise as _supervise from .. import supervise as _supervise
from ..bottle_state import read_metadata from ..bottle_state import read_metadata
# from ..backend.docker.capability_apply import (
# CapabilityApplyError,
# apply_capability_change,
# )
from ..backend.docker.egress_apply import ( from ..backend.docker.egress_apply import (
EgressApplyError, EgressApplyError,
applicator as _docker_applicator, applicator as _docker_applicator,
@@ -38,10 +33,6 @@ from ..backend.smolmachines.egress_apply import (
) )
from ..log import Die, error, info from ..log import Die, error, info
class CapabilityApplyError(RuntimeError):
"""Placeholder while capability_apply is disabled."""
from ..supervise import ( from ..supervise import (
COMPONENT_FOR_TOOL, COMPONENT_FOR_TOOL,
AuditEntry, AuditEntry,
@@ -50,12 +41,10 @@ from ..supervise import (
STATUS_APPROVED, STATUS_APPROVED,
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
archive_proposal,
list_pending_proposals, list_pending_proposals,
render_diff, render_diff,
write_audit_entry, write_audit_entry,
@@ -83,7 +72,7 @@ class QueuedProposal:
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps # handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses. # the proposal pending rather than crashing curses.
ApplyError = (CapabilityApplyError, EgressApplyError) ApplyError = (EgressApplyError,)
def apply_routes_change(slug: str, content: str) -> tuple[str, str]: def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
@@ -143,8 +132,6 @@ def _detail_lines(
def _suffix_for_tool(tool: str) -> str: def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile"
if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK): if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
return ".yaml" return ".yaml"
if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW): if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW):
@@ -166,17 +153,6 @@ def approve(
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", "" diff_before, diff_after = "", ""
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
# _meta = read_metadata(qp.proposal.bottle_slug)
# if _meta is not None and not _meta.compose_project:
# raise CapabilityApplyError(
# "capability-block remediation is not supported for smolmachines "
# "bottles. Reject this proposal or handle the capability change "
# "manually, then restart the bottle."
# )
# diff_before, diff_after = apply_capability_change(
# qp.proposal.bottle_slug, file_to_apply,
# )
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK): if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
diff_before, diff_after = apply_routes_change( diff_before, diff_after = apply_routes_change(
qp.proposal.bottle_slug, qp.proposal.bottle_slug,
@@ -194,9 +170,6 @@ def approve(
qp, action=status, notes=notes, qp, action=status, notes=notes,
diff_before=diff_before, diff_after=diff_after, diff_before=diff_before, diff_after=diff_after,
) )
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
archive_proposal(qp.queue_dir, qp.proposal.id)
def reject(qp: QueuedProposal, *, reason: str) -> None: def reject(qp: QueuedProposal, *, reason: str) -> None:
"""Write a rejection response and an audit entry.""" """Write a rejection response and an audit entry."""
@@ -346,7 +319,7 @@ def _list_once() -> int:
return 0 return 0
def _try_init_green() -> int: def _try_init_green() -> int: # pragma: no cover
"""Initialise a green color pair and return its attr, or 0.""" """Initialise a green color pair and return its attr, or 0."""
try: try:
curses.start_color() curses.start_color()
@@ -357,7 +330,7 @@ def _try_init_green() -> int:
return 0 return 0
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragma: no cover
curses.curs_set(0) curses.curs_set(0)
stdscr.timeout(_REFRESH_INTERVAL_MS) stdscr.timeout(_REFRESH_INTERVAL_MS)
green_attr = _try_init_green() green_attr = _try_init_green()
@@ -447,7 +420,7 @@ def _render(
status_line: str, status_line: str,
*, *,
green_attr: int = 0, # noqa: F841 — unused, but required by interface green_attr: int = 0, # noqa: F841 — unused, but required by interface
) -> None: ) -> None: # pragma: no cover
stdscr.erase() stdscr.erase()
h, w = stdscr.getmaxyx() h, w = stdscr.getmaxyx()
header = f"bot-bottle supervise ({len(pending)} pending)" header = f"bot-bottle supervise ({len(pending)} pending)"
@@ -498,7 +471,7 @@ def _detail_view(
qp: QueuedProposal, qp: QueuedProposal,
*, *,
green_attr: int = 0, green_attr: int = 0,
) -> None: ) -> None: # pragma: no cover
"""Render the full proposal. Scrollable. Press q to return.""" """Render the full proposal. Scrollable. Press q to return."""
lines = _detail_lines(qp, green_attr=green_attr) lines = _detail_lines(qp, green_attr=green_attr)
offset = 0 offset = 0
@@ -550,7 +523,7 @@ def _detail_view(
return return
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
"""Suspend curses, open $EDITOR on the proposed file, return edited content.""" """Suspend curses, open $EDITOR on the proposed file, return edited content."""
suffix = _suffix_for_tool(qp.proposal.tool) suffix = _suffix_for_tool(qp.proposal.tool)
curses.endwin() curses.endwin()
@@ -561,7 +534,7 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
return edited return edited
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore # pragma: no cover
"""One-line input at the bottom of the screen.""" """One-line input at the bottom of the screen."""
curses.curs_set(1) curses.curs_set(1)
h, _ = stdscr.getmaxyx() h, _ = stdscr.getmaxyx()
+1 -1
View File
@@ -21,7 +21,7 @@ FROM node:22-slim
# to it) works against egress's bumped TLS without the agent needing # to it) works against egress's bumped TLS without the agent needing
# local DNS. # local DNS.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl \ && apt-get install -y --no-install-recommends git ca-certificates curl ripgrep \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by claude-code itself # App-specific deps. Python isn't required by claude-code itself
+1 -1
View File
@@ -6,7 +6,7 @@
FROM node:22-slim FROM node:22-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl procps \ && apt-get install -y --no-install-recommends git ca-certificates curl procps ripgrep \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by codex itself # App-specific deps. Python isn't required by codex itself
@@ -21,6 +21,11 @@ from pathlib import Path
from ...deploy_key_provisioner import DeployKeyCollisionError, DeployKeyProvisioner from ...deploy_key_provisioner import DeployKeyCollisionError, DeployKeyProvisioner
# Timeout for ssh-keygen and Gitea API HTTP calls. A hung Gitea instance at
# prepare time would stall bottle launch indefinitely without this bound.
_API_TIMEOUT_SECS = 30
_KEYGEN_TIMEOUT_SECS = 10
class GiteaDeployKeyProvisioner(DeployKeyProvisioner): class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
"""Manages deploy keys on a Gitea instance.""" """Manages deploy keys on a Gitea instance."""
@@ -46,6 +51,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
check=True, check=True,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=_KEYGEN_TIMEOUT_SECS,
) )
private_key = key_path.read_bytes() private_key = key_path.read_bytes()
public_key = key_path.with_suffix(".pub").read_text().strip() public_key = key_path.with_suffix(".pub").read_text().strip()
@@ -67,7 +73,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
method="POST", method="POST",
) )
try: try:
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS) as resp:
body = json.loads(resp.read()) body = json.loads(resp.read())
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
_body = _read_error_body(exc) _body = _read_error_body(exc)
@@ -98,7 +104,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
method="DELETE", method="DELETE",
) )
try: try:
with urllib.request.urlopen(req): with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS):
pass pass
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if exc.code == 404: if exc.code == 404:
+21 -10
View File
@@ -210,6 +210,17 @@ def egress_token_env_map(
return out return out
def _yaml_str_escape(s: str) -> str:
"""Escape a string for use inside a YAML double-quoted scalar."""
return (
s.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
def _route_to_yaml_fields(r: Route) -> dict[str, object]: def _route_to_yaml_fields(r: Route) -> dict[str, object]:
fields: dict[str, object] = {"host": r.host} fields: dict[str, object] = {"host": r.host}
if r.auth_scheme and r.token_env: if r.auth_scheme and r.token_env:
@@ -272,12 +283,12 @@ def _render_match_entry(entry: dict[str, object]) -> list[str]:
for pd in entry["paths"]: # type: ignore[union-attr] for pd in entry["paths"]: # type: ignore[union-attr]
pd_dict: dict[str, str] = pd # type: ignore[assignment] pd_dict: dict[str, str] = pd # type: ignore[assignment]
if "type" in pd_dict: if "type" in pd_dict:
lines.append(f' - type: "{pd_dict["type"]}"') lines.append(f' - type: "{_yaml_str_escape(pd_dict["type"])}"')
lines.append(f' value: "{pd_dict["value"]}"') lines.append(f' value: "{_yaml_str_escape(pd_dict["value"])}"')
else: else:
lines.append(f' - value: "{pd_dict["value"]}"') lines.append(f' - value: "{_yaml_str_escape(pd_dict["value"])}"')
if "methods" in entry: if "methods" in entry:
methods_str = ", ".join(f'"{m}"' for m in entry["methods"]) # type: ignore[union-attr] methods_str = ", ".join(f'"{_yaml_str_escape(m)}"' for m in entry["methods"]) # type: ignore[union-attr]
prefix = " - " if first_key else " " prefix = " - " if first_key else " "
lines.append(f'{prefix}methods: [{methods_str}]') lines.append(f'{prefix}methods: [{methods_str}]')
first_key = False first_key = False
@@ -287,8 +298,8 @@ def _render_match_entry(entry: dict[str, object]) -> list[str]:
first_key = False first_key = False
for hd in entry["headers"]: # type: ignore[union-attr] for hd in entry["headers"]: # type: ignore[union-attr]
hd_dict: dict[str, str] = hd # type: ignore[assignment] hd_dict: dict[str, str] = hd # type: ignore[assignment]
lines.append(f' - name: "{hd_dict["name"]}"') lines.append(f' - name: "{_yaml_str_escape(hd_dict["name"])}"')
lines.append(f' value: "{hd_dict["value"]}"') lines.append(f' value: "{_yaml_str_escape(hd_dict["value"])}"')
if first_key: if first_key:
lines.append(" - {}") lines.append(" - {}")
return lines return lines
@@ -308,10 +319,10 @@ def egress_render_routes(
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
for r in routes: for r in routes:
f = _route_to_yaml_fields(r) f = _route_to_yaml_fields(r)
lines.append(f' - host: "{f["host"]}"') lines.append(f' - host: "{_yaml_str_escape(str(f["host"]))}"')
if "auth_scheme" in f: if "auth_scheme" in f:
lines.append(f' auth_scheme: "{f["auth_scheme"]}"') lines.append(f' auth_scheme: "{_yaml_str_escape(str(f["auth_scheme"]))}"')
lines.append(f' token_env: "{f["token_env"]}"') lines.append(f' token_env: "{_yaml_str_escape(str(f["token_env"]))}"')
if "matches" in f: if "matches" in f:
lines.append(" matches:") lines.append(" matches:")
for entry in f["matches"]: # type: ignore[union-attr] for entry in f["matches"]: # type: ignore[union-attr]
@@ -331,7 +342,7 @@ def egress_render_routes(
items_str = ", ".join(f'"{x}"' for x in dv) items_str = ", ".join(f'"{x}"' for x in dv)
lines.append(f" {dk}: [{items_str}]") lines.append(f" {dk}: [{items_str}]")
elif isinstance(dv, str): elif isinstance(dv, str):
lines.append(f' {dk}: "{dv}"') lines.append(f' {dk}: "{_yaml_str_escape(dv)}"')
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
+17 -6
View File
@@ -43,10 +43,10 @@ from .manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the sidecar bundle. The # Short network alias for git-gate inside the sidecar bundle. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name. # agent's `.gitconfig` insteadOf rewrites resolve through this name.
GIT_GATE_HOSTNAME = "git-gate" GIT_GATE_HOSTNAME = "git-gate"
# Bound half-open git client sessions. If an agent/tool runner is # Shared timeout (seconds) for all git-gate subprocess and CGI calls:
# interrupted during push, git daemon should reap the receive-pack # git daemon (--timeout/--init-timeout), the access-hook subprocess in
# child instead of keeping the gate wedged indefinitely. # git_http_backend, and the git http-backend CGI subprocess.
GIT_GATE_DAEMON_TIMEOUT_SECS = 15 GIT_GATE_TIMEOUT_SECS = 15
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -112,6 +112,15 @@ def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstre
) )
def _gitconfig_validate_value(field: str, value: str) -> None:
"""Raise ValueError if value contains characters that break gitconfig line syntax."""
if "\n" in value or "\r" in value:
raise ValueError(
f"git-gate: {field} contains a newline, which would inject "
f"arbitrary gitconfig keys; rejecting manifest entry"
)
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
) -> str: ) -> str:
@@ -136,6 +145,7 @@ def git_gate_render_gitconfig(
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n", "# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
] ]
for entry in entries: for entry in entries:
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n') out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
out.append(f"\tinsteadOf = {entry.Upstream}\n") out.append(f"\tinsteadOf = {entry.Upstream}\n")
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost: if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
@@ -148,6 +158,7 @@ def git_gate_render_gitconfig(
f"ssh://{entry.UpstreamUser}@{entry.RemoteKey}{port}/" f"ssh://{entry.UpstreamUser}@{entry.RemoteKey}{port}/"
f"{entry.UpstreamPath}" f"{entry.UpstreamPath}"
) )
_gitconfig_validate_value(f"repos[{entry.Name!r}].url (resolved alias)", alias)
out.append(f"\tinsteadOf = {alias}\n") out.append(f"\tinsteadOf = {alias}\n")
return "".join(out) return "".join(out)
@@ -217,8 +228,8 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
"", "",
"exec git daemon \\", "exec git daemon \\",
" --reuseaddr \\", " --reuseaddr \\",
f" --timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\", f" --timeout={GIT_GATE_TIMEOUT_SECS} \\",
f" --init-timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\", f" --init-timeout={GIT_GATE_TIMEOUT_SECS} \\",
" --base-path=/git \\", " --base-path=/git \\",
" --export-all \\", " --export-all \\",
" --enable=receive-pack \\", " --enable=receive-pack \\",
+11 -1
View File
@@ -16,6 +16,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit from urllib.parse import urlsplit
from .git_gate import GIT_GATE_TIMEOUT_SECS
DEFAULT_PORT = 9420 DEFAULT_PORT = 9420
@@ -47,6 +49,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
[hook_path, "upload-pack", str(repo_dir), peer, peer], [hook_path, "upload-pack", str(repo_dir), peer, peer],
capture_output=True, capture_output=True,
check=False, check=False,
timeout=GIT_GATE_TIMEOUT_SECS,
) )
if hook.returncode != 0: if hook.returncode != 0:
detail = (hook.stderr or hook.stdout).decode( detail = (hook.stderr or hook.stdout).decode(
@@ -110,6 +113,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
env=env, env=env,
capture_output=True, capture_output=True,
check=False, check=False,
timeout=GIT_GATE_TIMEOUT_SECS,
) )
self._write_cgi_response(proc.stdout) self._write_cgi_response(proc.stdout)
@@ -148,7 +152,13 @@ class GitHttpHandler(BaseHTTPRequestHandler):
key, _, value = line.decode("latin1").partition(":") key, _, value = line.decode("latin1").partition(":")
value = value.strip() value = value.strip()
if key.lower() == "status": if key.lower() == "status":
status = int(value.split()[0]) try:
status = int(value.split()[0])
except (ValueError, IndexError):
self.log_message(
"malformed CGI Status header %r; using 500", value,
)
status = 500
else: else:
headers.append((key, value)) headers.append((key, value))
self.send_response(status) self.send_response(status)
+2 -4
View File
@@ -113,10 +113,8 @@ class ManifestBottle:
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the # Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
# default, issue #249), the launch step brings up a supervise # default, issue #249), the launch step brings up a supervise
# sidecar that exposes MCP tools to the agent (egress-block, # sidecar that exposes egress MCP tools to the agent. Set
# capability-block) plus mounts the current-config dir read-only # `supervise: false` to skip the sidecar.
# into the agent at /etc/bot-bottle/current-config. Set
# `supervise: false` to skip the sidecar and mount.
supervise: bool = True supervise: bool = True
@classmethod @classmethod
+110 -18
View File
@@ -49,33 +49,125 @@ def _resolve_one_bottle(
repos_cache[name] = _resolve_repos_raw({}, child_raw) repos_cache[name] = _resolve_repos_raw({}, child_raw)
return bottle return bottle
if not isinstance(parent_name_raw, str): # Normalize to list, accepting both str and list[str].
raw_list: list[object]
if isinstance(parent_name_raw, str):
raw_list = [parent_name_raw]
elif isinstance(parent_name_raw, list):
raw_list = parent_name_raw
else:
raise ManifestError( raise ManifestError(
f"bottle '{name}' extends must be a string " f"bottle '{name}' extends must be a string or list of strings "
f"(was {type(parent_name_raw).__name__})" f"(was {type(parent_name_raw).__name__})"
) )
parent_name: str = parent_name_raw
if parent_name == name: # Validate each entry before resolving any of them.
raise ManifestError( parent_names: list[str] = []
f"bottle '{name}' extends itself; remove the " for i, pname in enumerate(raw_list):
f"self-reference" if not isinstance(pname, str):
) raise ManifestError(
if parent_name not in raws: f"bottle '{name}' extends[{i}] must be a string "
avail = ", ".join(sorted(raws.keys())) or "(none)" f"(was {type(pname).__name__})"
raise ManifestError( )
f"bottle '{name}' extends '{parent_name}' which is not " parent_names.append(pname)
f"defined. Available bottles: {avail}" if pname == name:
) raise ManifestError(
parent = _resolve_one_bottle( f"bottle '{name}' extends itself; remove the self-reference"
parent_name, raws, cache, repos_cache, seen + (name,) )
if pname not in raws:
avail = ", ".join(sorted(raws.keys())) or "(none)"
raise ManifestError(
f"bottle '{name}' extends '{pname}' which is not "
f"defined. Available bottles: {avail}"
)
combined_parent, combined_repos_raw = _fold_parents(
parent_names, raws, cache, repos_cache, seen + (name,)
) )
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw) merged_repos_raw = _resolve_repos_raw(combined_repos_raw, child_raw)
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name) bottle = _merge_bottles(combined_parent, child_raw, merged_repos_raw, name)
cache[name] = bottle cache[name] = bottle
repos_cache[name] = merged_repos_raw repos_cache[name] = merged_repos_raw
return bottle return bottle
def _fold_parents(
parent_names: list[str],
raws: dict[str, dict[str, object]],
cache: dict[str, ManifestBottle],
repos_cache: dict[str, dict[str, object]],
seen: tuple[str, ...],
) -> tuple[ManifestBottle, dict[str, object]]:
"""Resolve each parent and fold them left-to-right.
Later parents win over earlier ones on conflict. The `seen` tuple
carries the current bottle's name so cycle detection works across
every parent edge in the multi-parent graph."""
first = parent_names[0]
effective = _resolve_one_bottle(first, raws, cache, repos_cache, seen)
effective_repos_raw = repos_cache[first]
for pname in parent_names[1:]:
later = _resolve_one_bottle(pname, raws, cache, repos_cache, seen)
later_repos_raw = repos_cache[pname]
effective, effective_repos_raw = _fold_two_bottles(
effective, effective_repos_raw, later, later_repos_raw
)
return effective, effective_repos_raw
def _fold_two_bottles(
earlier: ManifestBottle,
earlier_repos_raw: dict[str, object],
later: ManifestBottle,
later_repos_raw: dict[str, object],
) -> tuple[ManifestBottle, dict[str, object]]:
"""Combine two resolved parent bottles; later wins over earlier."""
from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import ManifestEgressConfig
from .manifest_git import parse_git_gate_config
from .manifest_util import as_json_object
merged_env = {**earlier.env, **later.env}
merged_git_user = ManifestGitUser(
name=later.git_user.name or earlier.git_user.name,
email=later.git_user.email or earlier.git_user.email,
)
# Repos: union by name; for same-name entries, later wins per-field.
# Unlike _resolve_repos_raw, an empty later_repos_raw means "no repos
# declared" — it does NOT clear the earlier parent's repos.
names = list(earlier_repos_raw) + [
n for n in later_repos_raw if n not in earlier_repos_raw
]
merged_repos_raw: dict[str, object] = {
n: {
**as_json_object(earlier_repos_raw.get(n, {}), "earlier parent repo"),
**as_json_object(later_repos_raw.get(n, {}), "later parent repo"),
}
for n in names
}
if merged_repos_raw:
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
else:
merged_git = ()
# Egress: routes concatenate; scalar fields use last-wins.
merged_egress = ManifestEgressConfig(
routes=earlier.egress.routes + later.egress.routes,
Log=later.egress.Log,
)
return ManifestBottle(
env=merged_env,
agent_provider=later.agent_provider,
git=merged_git,
git_user=merged_git_user,
egress=merged_egress,
supervise=later.supervise,
), merged_repos_raw
def _merge_bottles( def _merge_bottles(
parent: ManifestBottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
+2
View File
@@ -87,5 +87,7 @@ def load_bottle_chain_from_dir(
parent = fm.get("extends") parent = fm.get("extends")
if isinstance(parent, str): if isinstance(parent, str):
to_load.append(parent) to_load.append(parent)
elif isinstance(parent, list):
to_load.extend(p for p in parent if isinstance(p, str))
return resolve_bottles(raws)[bottle_name] return resolve_bottles(raws)[bottle_name]
+10 -42
View File
@@ -2,11 +2,10 @@
The supervise plane is the per-bottle MCP sidecar plus its host-side The supervise plane is the per-bottle MCP sidecar plus its host-side
queue/audit support. The sidecar (bot_bottle.supervise_server) queue/audit support. The sidecar (bot_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the sits on the bottle's internal network and exposes MCP tools the agent
agent calls when it hits a stuck-recovery category: calls when it needs an operator-reviewed egress change:
* egress-block / allow agent proposes a new routes.yaml * egress-block / allow agent proposes a new routes.yaml
* capability-block agent proposes a new agent Dockerfile
Each tool call: the agent passes the full proposed file plus a Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
@@ -48,7 +47,6 @@ from pathlib import Path
SUPERVISE_HOSTNAME = "supervise" SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_BLOCK = "egress-block"
TOOL_EGRESS_ALLOW = "egress-allow" TOOL_EGRESS_ALLOW = "egress-allow"
TOOL_GITLEAKS_ALLOW = "gitleaks-allow" TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
@@ -58,7 +56,6 @@ TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = ( TOOLS: tuple[str, ...] = (
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
@@ -75,10 +72,6 @@ TOOLS: tuple[str, ...] = (
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099" EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
# capability-block has no on-disk config the operator edits in place
# (the Dockerfile is rebuilt, not patched), so it has no audit log
# here — those changes are captured by git history + the rebuild record
# laid down in PRD 0016.
COMPONENT_FOR_TOOL: dict[str, str] = { COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_EGRESS_ALLOW: "egress", TOOL_EGRESS_ALLOW: "egress",
TOOL_EGRESS_BLOCK: "egress", TOOL_EGRESS_BLOCK: "egress",
@@ -94,8 +87,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
ACTION_OPERATOR_EDIT = "operator-edit" ACTION_OPERATOR_EDIT = "operator-edit"
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
CURRENT_CONFIG_DIR_IN_AGENT = "/etc/bot-bottle/current-config"
DEFAULT_POLL_INTERVAL_SEC = 0.5 DEFAULT_POLL_INTERVAL_SEC = 0.5
@@ -438,59 +429,39 @@ def sha256_hex(content: str) -> str:
# --- Sidecar plan + abstract lifecycle ------------------------------------- # --- Sidecar plan + abstract lifecycle -------------------------------------
# Filename of the staged Dockerfile inside the agent's read-only
# current-config mount. The capability-block tool's description
# points the agent at this exact path so it can read the current
# Dockerfile and propose modifications.
#
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
# moved them behind the `list-egress-routes` MCP tool (live state
# from egress's introspection endpoint) so the agent always sees
# current data rather than a launch-time snapshot.
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
@dataclass(frozen=True) @dataclass(frozen=True)
class SupervisePlan: class SupervisePlan:
"""Output of Supervise.prepare; consumed by .start. """Output of Supervise.prepare; consumed by .start.
`queue_dir` is the host directory bind-mounted into the sidecar `queue_dir` is the host directory bind-mounted into the sidecar
at /run/supervise/queue. `current_config_dir` is the host at /run/supervise/queue. `internal_network` is empty at prepare
directory bind-mounted (read-only) into the *agent* container time; the backend's launch step fills it via dataclasses.replace
at /etc/bot-bottle/current-config currently holds only the before calling .start."""
Dockerfile snapshot (routes.yaml + allowlist moved to the
`list-egress-routes` MCP tool). `internal_network` is
empty at prepare time; the backend's launch step fills it via
dataclasses.replace before calling .start."""
slug: str slug: str
queue_dir: Path queue_dir: Path
current_config_dir: Path
internal_network: str = "" internal_network: str = ""
class Supervise(ABC): class Supervise(ABC):
"""Per-bottle supervise sidecar. Encapsulates the host-side """Per-bottle supervise sidecar. Encapsulates the host-side
prepare (queue dir + current-config staging); the sidecar's prepare (queue dir staging); the sidecar's start/stop lifecycle
start/stop lifecycle is backend-specific.""" is backend-specific."""
def prepare( def prepare(
self, self,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
) -> SupervisePlan: ) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the """Stage the per-bottle queue dir on the host. Returns the
current-config dir under `stage_dir`. Returns the plan; plan; `internal_network` must be set by the launch step before
`internal_network` must be set by the launch step before
.start runs.""" .start runs."""
del stage_dir
queue_dir = queue_dir_for_slug(slug) queue_dir = queue_dir_for_slug(slug)
queue_dir.mkdir(parents=True, exist_ok=True) queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True)
return SupervisePlan( return SupervisePlan(
slug=slug, slug=slug,
queue_dir=queue_dir, queue_dir=queue_dir,
current_config_dir=current_config_dir,
) )
# --- Helpers --------------------------------------------------------------- # --- Helpers ---------------------------------------------------------------
@@ -541,8 +512,6 @@ __all__ = [
"ACTION_OPERATOR_EDIT", "ACTION_OPERATOR_EDIT",
"AuditEntry", "AuditEntry",
"COMPONENT_FOR_TOOL", "COMPONENT_FOR_TOOL",
"CURRENT_CONFIG_DIR_IN_AGENT",
"CURRENT_CONFIG_DOCKERFILE",
"DEFAULT_POLL_INTERVAL_SEC", "DEFAULT_POLL_INTERVAL_SEC",
"Proposal", "Proposal",
"QUEUE_DIR_IN_CONTAINER", "QUEUE_DIR_IN_CONTAINER",
@@ -558,7 +527,6 @@ __all__ = [
"TOOLS", "TOOLS",
"EGRESS_FORWARD_PROXY", "EGRESS_FORWARD_PROXY",
"EGRESS_INTROSPECT_URL", "EGRESS_INTROSPECT_URL",
"TOOL_CAPABILITY_BLOCK",
"TOOL_EGRESS_ALLOW", "TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK", "TOOL_EGRESS_BLOCK",
"TOOL_GITLEAKS_ALLOW", "TOOL_GITLEAKS_ALLOW",
+52 -61
View File
@@ -1,8 +1,8 @@
"""Supervise sidecar HTTP server (PRD 0013). """Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing tools the agent calls to propose config Per-bottle MCP server exposing tools the agent calls to propose egress
changes when stuck. The tools are `allow`, `egress-block`, config changes when stuck. The tools are `egress-allow`,
`capability-block`, and `list-egress-routes`. `egress-block`, and `list-egress-routes`.
Each queued tool call: Each queued tool call:
@@ -90,19 +90,19 @@ def parse_jsonrpc(body: bytes) -> JsonRpcRequest:
try: try:
raw = json.loads(body) raw = json.loads(body)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise _RpcError(ERR_PARSE, f"parse error: {e}") from e raise _RpcClientError(ERR_PARSE, f"parse error: {e}") from e
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise _RpcError(ERR_INVALID_REQUEST, "request must be a JSON object") raise _RpcClientError(ERR_INVALID_REQUEST, "request must be a JSON object")
if raw.get("jsonrpc") != JSONRPC_VERSION: if raw.get("jsonrpc") != JSONRPC_VERSION:
raise _RpcError(ERR_INVALID_REQUEST, "jsonrpc field must be '2.0'") raise _RpcClientError(ERR_INVALID_REQUEST, "jsonrpc field must be '2.0'")
method = raw.get("method") method = raw.get("method")
if not isinstance(method, str): if not isinstance(method, str):
raise _RpcError(ERR_INVALID_REQUEST, "method must be a string") raise _RpcClientError(ERR_INVALID_REQUEST, "method must be a string")
params = raw.get("params", {}) params = raw.get("params", {})
if params is None: if params is None:
params = {} params = {}
if not isinstance(params, dict): if not isinstance(params, dict):
raise _RpcError(ERR_INVALID_PARAMS, "params must be an object") raise _RpcClientError(ERR_INVALID_PARAMS, "params must be an object")
rpc_id = raw.get("id", _NO_ID) rpc_id = raw.get("id", _NO_ID)
is_notification = rpc_id is _NO_ID is_notification = rpc_id is _NO_ID
return JsonRpcRequest( return JsonRpcRequest(
@@ -117,12 +117,23 @@ _NO_ID = object()
class _RpcError(Exception): class _RpcError(Exception):
"""Base class for all typed RPC errors that surface as JSON-RPC error responses."""
def __init__(self, code: int, message: str): def __init__(self, code: int, message: str):
super().__init__(message) super().__init__(message)
self.code = code self.code = code
self.message = message self.message = message
class _RpcClientError(_RpcError):
"""Caller sent a bad request; returned verbatim, no server-side logging."""
class _RpcInternalError(_RpcError):
"""Server-side fault; logged at ERROR with cause, always returns ERR_INTERNAL."""
def __init__(self, message: str) -> None:
super().__init__(ERR_INTERNAL, message)
def jsonrpc_result(request_id: object, result: object) -> bytes: def jsonrpc_result(request_id: object, result: object) -> bytes:
payload = {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result} payload = {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
return (json.dumps(payload) + "\n").encode("utf-8") return (json.dumps(payload) + "\n").encode("utf-8")
@@ -242,34 +253,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"required": ["routes_yaml", "justification"], "required": ["routes_yaml", "justification"],
}, },
}, },
{
"name": _sv.TOOL_CAPABILITY_BLOCK,
"description": (
"Call when the bottle is missing a tool, skill, permission, "
"or env var you need — something that lives in the agent "
"Dockerfile rather than in the egress routes. "
"Read the current Dockerfile from "
"/etc/bot-bottle/current-config/Dockerfile, compose a "
"modified version, and pass the full new file plus a "
"justification. On approval the supervisor rebuilds the "
"bottle from the new Dockerfile and starts a replacement on "
"the same branch (wired in PRD 0016; v1 acknowledges only)."
),
"inputSchema": {
"type": "object",
"properties": {
"dockerfile": {
"type": "string",
"description": "Full proposed Dockerfile content.",
},
"justification": {
"type": "string",
"description": "Why this capability is needed.",
},
},
"required": ["dockerfile", "justification"],
},
},
] ]
@@ -277,7 +260,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
# payload (stored in Proposal.proposed_file). # payload (stored in Proposal.proposed_file).
PROPOSED_FILE_FIELD: dict[str, str] = { PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_EGRESS_ALLOW: "routes_yaml", _sv.TOOL_EGRESS_ALLOW: "routes_yaml",
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
_sv.TOOL_EGRESS_BLOCK: "routes_yaml", _sv.TOOL_EGRESS_BLOCK: "routes_yaml",
} }
@@ -290,26 +272,22 @@ def validate_proposed_file(tool: str, content: str) -> None:
catches obvious paste-errors / wrong-tool selections before they catches obvious paste-errors / wrong-tool selections before they
enter the queue.""" enter the queue."""
if not content.strip(): if not content.strip():
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty") raise _RpcClientError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
if tool == _sv.TOOL_CAPABILITY_BLOCK: if tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
# Dockerfiles are too varied to validate syntactically beyond
# non-empty. The operator reads the diff in the TUI.
pass
elif tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
try: try:
config = load_config(content) config = load_config(content)
except ValueError as e: except ValueError as e:
raise _RpcError( raise _RpcClientError(
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
f"{tool}: proposed routes.yaml is not valid: {e}", f"{tool}: proposed routes.yaml is not valid: {e}",
) from e ) from e
if config.log != LOG_OFF: if config.log != LOG_OFF:
raise _RpcError( raise _RpcClientError(
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
f"{tool}: proposed routes.yaml must not change egress logging", f"{tool}: proposed routes.yaml must not change egress logging",
) )
else: else:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}") raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
# --- MCP handlers ---------------------------------------------------------- # --- MCP handlers ----------------------------------------------------------
@@ -382,17 +360,17 @@ def handle_tools_call(
doesn't need operator approval.""" doesn't need operator approval."""
name = params.get("name") name = params.get("name")
if not isinstance(name, str): if not isinstance(name, str):
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'") raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
if name == _sv.TOOL_LIST_EGRESS_ROUTES: if name == _sv.TOOL_LIST_EGRESS_ROUTES:
return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config) return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config)
args_raw = params.get("arguments", {}) args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict): if not isinstance(args_raw, dict):
raise _RpcError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object") raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
justification = args_raw.get("justification") justification = args_raw.get("justification")
if not isinstance(justification, str) or not justification.strip(): if not isinstance(justification, str) or not justification.strip():
raise _RpcError( raise _RpcClientError(
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
f"{name}: 'justification' is required and must be a non-empty string", f"{name}: 'justification' is required and must be a non-empty string",
) )
@@ -401,13 +379,13 @@ def handle_tools_call(
file_field = PROPOSED_FILE_FIELD[name] file_field = PROPOSED_FILE_FIELD[name]
proposed_file = args_raw.get(file_field) proposed_file = args_raw.get(file_field)
if not isinstance(proposed_file, str): if not isinstance(proposed_file, str):
raise _RpcError( raise _RpcClientError(
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
f"{name}: '{file_field}' is required and must be a string", f"{name}: '{file_field}' is required and must be a string",
) )
validate_proposed_file(name, proposed_file) validate_proposed_file(name, proposed_file)
else: else:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}") raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
proposal = _sv.Proposal.new( proposal = _sv.Proposal.new(
bottle_slug=config.bottle_slug, bottle_slug=config.bottle_slug,
@@ -416,7 +394,10 @@ def handle_tools_call(
justification=justification, justification=justification,
current_file_hash=_sv.sha256_hex(proposed_file), current_file_hash=_sv.sha256_hex(proposed_file),
) )
_sv.write_proposal(config.queue_dir, proposal) try:
_sv.write_proposal(config.queue_dir, proposal)
except OSError as e:
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
sys.stderr.write( sys.stderr.write(
f"supervise: queued proposal {proposal.id} ({name}) " f"supervise: queued proposal {proposal.id} ({name}) "
f"for bottle {config.bottle_slug}; waiting for operator...\n" f"for bottle {config.bottle_slug}; waiting for operator...\n"
@@ -436,7 +417,10 @@ def handle_tools_call(
"content": [{"type": "text", "text": text}], "content": [{"type": "text", "text": text}],
"isError": False, "isError": False,
} }
_sv.archive_proposal(config.queue_dir, proposal.id) try:
_sv.archive_proposal(config.queue_dir, proposal.id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
text = format_response_text(response) text = format_response_text(response)
return { return {
@@ -470,9 +454,8 @@ def format_pending_response_text(timeout_seconds: float) -> str:
# --- HTTP transport -------------------------------------------------------- # --- HTTP transport --------------------------------------------------------
# Max request body the server accepts. Generous because Dockerfile # Max request body the server accepts. 1 MB is well above any realistic
# proposals can be a few KB; routes.json is small. 1 MB is well above # routes.yaml proposal.
# any realistic config file.
MAX_BODY_BYTES = 1 * 1024 * 1024 MAX_BODY_BYTES = 1 * 1024 * 1024
@@ -512,7 +495,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
try: try:
req = parse_jsonrpc(body) req = parse_jsonrpc(body)
except _RpcError as e: except _RpcClientError as e:
self._write_jsonrpc(jsonrpc_error(None, e.code, e.message)) self._write_jsonrpc(jsonrpc_error(None, e.code, e.message))
return return
@@ -520,11 +503,19 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
try: try:
result = self._dispatch(req, config) result = self._dispatch(req, config)
except _RpcError as e: except _RpcClientError as e:
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message)) self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
return return
except Exception as e: # noqa: W0718 — catch-all for RPC dispatch errors except _RpcInternalError as e:
sys.stderr.write(f"supervise: internal error: {e}\n") cause = e.__cause__
detail = f": {cause}" if cause else ""
sys.stderr.write(f"supervise: internal error: {e.message}{detail}\n")
sys.stderr.flush()
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
return
except Exception as e: # noqa: W0718 — unexpected errors
sys.stderr.write(f"supervise: unexpected error: {type(e).__name__}: {e}\n")
sys.stderr.flush()
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error")) self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
return return
@@ -543,7 +534,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
return handle_tools_list(req.params) return handle_tools_list(req.params)
if method == "tools/call": if method == "tools/call":
return handle_tools_call(req.params, config) return handle_tools_call(req.params, config)
raise _RpcError(ERR_METHOD_NOT_FOUND, f"method not found: {method}") raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
def _write_jsonrpc(self, body: bytes) -> None: def _write_jsonrpc(self, body: bytes) -> None:
self.send_response(200) self.send_response(200)
+166
View File
@@ -0,0 +1,166 @@
# PRD 0065: Multi-parent `extends:` for bottles
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-06-25
- **Issue:** #268
- **Extends:** PRD 0025 (`0025-bottle-extends.md`)
## Summary
Allow a bottle's `extends:` field to accept either a single bottle name (existing
behavior) or a list of bottle names (new). Multiple parents are resolved
independently and folded left-to-right into a single effective parent before the
child is merged on top. This lets orthogonal concerns (base env, networking/egress,
agent provider) live in separate bottles and be composed without forcing them into a
linear chain.
## Problem
PRD 0025 shipped single-parent `extends:` and listed "No multi-parent inheritance"
as a non-goal. In practice, users want to compose multiple orthogonal bottles — a
base environment, a networking profile, and an agent-provider override — without
creating a three-level linear chain that couples unrelated parents to each other.
The linear chain workaround has two problems:
1. **Ordering constraint.** `networking extends base` works, but then
`agent extends networking` can't also pick up `base` without going through
`networking`, coupling two unrelated concerns.
2. **Quadratic duplication.** N orthogonal bottles require O(N²) chain variants
(one chain per permutation of applied concerns).
Multi-parent `extends:` removes both constraints: each orthogonal concern stays in
its own bottle, and the child bottle is the only place that names the combination.
## Goals / Success Criteria
- `extends:` accepts a list of strings in addition to a plain string.
- Backward compat: existing single-string `extends:` is unchanged.
- Parents are resolved left-to-right; later entries win on conflict.
- Child wins over all parents (unchanged from PRD 0025).
- Cycle detection covers multi-parent graphs, not just linear chains.
- Diamond inheritance: a shared ancestor is resolved once (via the existing cache).
- Invalid list entries (non-string, undefined bottle, self-reference) die at parse
with clear messages.
- `manifest_loader.py`'s `load_bottle_chain_from_dir` enqueues all parents from a
list `extends:` so the resolver sees every bottle in the graph.
## Non-goals
- No change to the agent-vs-bottle trust boundary (PRD 0025 "Alternatives
considered" option 2 stays rejected).
- No MRO / C3 linearization. Left-to-right fold is sufficient for the expected use
cases.
- No preflight display of per-field provenance across multiple parents (same open
question as PRD 0025; remains a follow-up).
## Design
### Schema
`extends:` now accepts either form:
```yaml
# single parent (unchanged)
extends: base
# multiple parents (new)
extends: [base, networking]
```
Both forms are normalized to a list internally. A list with one element behaves
identically to the string form.
### Merge rules for multi-parent fold
Parents are folded pairwise left-to-right before the child merge. For each step in
the fold, the "earlier" bottle is the running accumulator and the "later" bottle is
the next parent. Rules per field:
| Field | Fold rule |
|--------------------|--------------------------------------------------------------|
| `env` | dict merge; later wins on key collision |
| `git-gate.user` | per-field overlay; later's non-empty fields win |
| `git-gate.repos` | union by name; for same-name entries, later wins per-field |
| `egress.routes` | concatenate (earlier first, later appended) |
| `egress.log` | later wins (last-wins) |
| `agent_provider` | later wins (last-wins) |
| `supervise` | later wins (last-wins) |
After the fold, the combined parent is merged against the child using the existing
PRD 0025 rules (child always wins). The child's `egress.routes` appends to the
combined parent's concatenated routes; `validate_egress_routes` runs once on the
final merged set and catches duplicate hosts.
### Algorithm
```
extends: [p1, p2, p3]
fold:
combined = resolve(p1)
combined = fold_two(combined, resolve(p2))
combined = fold_two(combined, resolve(p3))
merge:
result = _merge_bottles(combined, child_raw, name)
```
`fold_two(earlier, later)` applies the rules in the table above. Cycle detection
(the `seen` tuple) is passed to each parent resolution call unchanged — if any
parent's chain circles back to the current bottle, it is caught. The `cache` dict
ensures a shared ancestor is only resolved once across all parents.
### Error cases
| Condition | Error message shape |
|----------------------------------------|------------------------------------------------------------------|
| `extends` is not a string or list | `extends must be a string or list of strings (was <type>)` |
| A list entry is not a string | `extends[<i>] must be a string (was <type>)` |
| A list entry names an undefined bottle | `extends '<name>' which is not defined. Available bottles: ...` |
| A list entry is the bottle itself | `extends itself; remove the self-reference` |
| Cycle through any parent edge | `is in an extends cycle: <chain>` |
## Implementation
### `bot_bottle/manifest_extends.py`
- `_resolve_one_bottle`: accept `str | list[str]` for `extends`; normalize to list;
validate each entry; for a single-entry list fall through to the existing
single-parent path; for multiple entries call `_fold_parents` then
`_merge_bottles`.
- `_fold_parents(parent_names, raws, cache, repos_cache, seen)`: resolve each
parent and fold pairwise left-to-right; return `(effective_bottle,
effective_repos_raw)`.
- `_fold_two_bottles(earlier, earlier_repos_raw, later, later_repos_raw)`: apply
the fold rules above; return `(folded_bottle, folded_repos_raw)`.
### `bot_bottle/manifest_loader.py`
- `load_bottle_chain_from_dir`: when `extends` is a list, enqueue all parent names
for loading (previously only `isinstance(parent, str)` was handled).
### `tests/unit/test_manifest_extends.py`
- `TestExtendsErrors.test_non_string_extends_dies`: update to use an integer
`extends` value (a list is now valid).
- New class `TestExtendsMultiParent` covering all cases listed in the issue.
## Testing strategy
Unit tests via `ManifestIndex.from_json_obj` (same resolver surface used by all
paths). No integration test changes needed — downstream code consumes the already-
merged bottle and is unchanged.
Test cases:
- Two-parent list: env union, egress routes concat, git repos union
- Last-parent-wins on scalar (supervise, agent_provider)
- Child wins over all parents on conflict
- Diamond: two parents share an ancestor; ancestor resolved once
- Single-element list: identical to string form
- Non-string extends value → ManifestError
- Non-string list entry → ManifestError
- Undefined bottle in list → ManifestError
- Self-reference in list → ManifestError
- Cycle through multi-parent edge → ManifestError
+227
View File
@@ -0,0 +1,227 @@
# PRD prd-new: smolmachines backend on Linux
- **Status:** Draft
- **Author:** Claude
- **Created:** 2026-06-25
- **Issue:** #283
## Summary
Make the `smolmachines` backend (PRD 0023) runnable on Linux, not
just macOS. `smolvm` already supports Linux via KVM (`/dev/kvm`);
the gap is entirely in bot-bottle's host-side glue, which hard-codes
macOS assumptions in three places:
1. **Preflight** only checks that `smolvm` is on `PATH` — it never
checks the Linux KVM prerequisite, so a misconfigured host fails
deep in the launch flow with an opaque `smolvm` error.
2. **The TSI allowlist enforcement** (`force_allowlist`) — the
security property that confines the agent VM to its sidecar
bundle's `/32`**no-ops on Linux today, failing _open_**. The
smolvm state-DB path it patches is hard-coded to macOS's
`~/Library/Application Support/...`.
3. **Per-bottle loopback scoping** (`allocate`) returns the shared
`127.0.0.1` on Linux, which would let the agent VM reach every
service on host loopback — a downgrade from the per-bottle alias
isolation macOS gets.
This PRD closes all three so a bottle launched with
`BOT_BOTTLE_BACKEND=smolmachines` on Linux gets the same isolation
guarantee it gets on macOS, and documents the Linux/NixOS host
setup. The primary validation target is NixOS, but the changes are
distro-agnostic.
## Problem
The smolmachines backend runs each bottle's agent inside a libkrun
microVM via `smolvm`, with egress confined by TSI's `--allow-cidr`
allowlist set to a single `/32` — the sidecar bundle's loopback
address. Everything else (host loopback, LAN, internet) is denied at
the VMM layer. That security property is the entire reason the
backend exists.
libkrun runs on Hypervisor.framework (macOS) **and** KVM (Linux), and
`smolvm` ships Linux x86_64 / aarch64 builds that require `/dev/kvm`.
So the microVM layer already works on Linux. What does not work is
bot-bottle's host integration, which PRD 0023 explicitly scoped to
macOS-only for v1. Three concrete blockers:
- **No KVM preflight.** On a Linux host without `/dev/kvm` (kernel
module not loaded) or without access to it (user not in the `kvm`
group), the failure surfaces as a cryptic `smolvm` non-zero exit
mid-launch instead of an actionable message.
- **TSI enforcement fails open on Linux.** `force_allowlist`
early-returns on non-macOS. It exists because `smolvm` 0.8.0
silently drops `--allow-cidr` when combined with `--from`, so the
allowlist has to be patched into smolvm's persisted state DB before
`machine start`. On Linux that patch never runs **and** the DB path
is the macOS path, so the booted VM's TSI allowlist is whatever
smolvm defaulted to — potentially all of `127.0.0.0/8`. That is the
exact sandbox-escape the backend is supposed to prevent.
- **No per-bottle loopback isolation on Linux.** `allocate` returns
`127.0.0.1` on Linux. Even with a correct allowlist, `127.0.0.1/32`
is shared by every service on host loopback, so the agent could
reach other bottles' published ports and host services. On macOS
this is solved with per-bottle `127.0.0.16..31` aliases added via
`sudo ifconfig lo0 alias`. On Linux the whole `127.0.0.0/8` is
already routed to `lo`, so docker can publish to `127.0.0.<N>`
with **no `ifconfig`/sudo step at all** — the isolation is actually
cheaper to achieve than on macOS.
## Goals / Success Criteria
- `BOT_BOTTLE_BACKEND=smolmachines ./cli.py 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
`BOT_BOTTLE_BACKEND=smolmachines` on Linux (the acceptance gate).
- Each Linux bottle is scoped to its own `127.0.0.<N>/32`, matching
the macOS per-bottle isolation property.
- A clear, actionable preflight error when `/dev/kvm` is missing or
inaccessible, with remediation (load `kvm-intel`/`kvm-amd`, join the
`kvm` group).
- **Fail-closed:** if bot-bottle cannot positively confirm the TSI
allowlist was persisted for a machine (DB missing, row missing,
patch didn't take), it `die()`s before `machine start` rather than
booting a VM with an unverified allowlist.
- macOS behavior is unchanged.
- README documents Linux + NixOS host setup.
## Non-goals
- Rootless / non-KVM fallbacks (e.g. software emulation). Linux
smolmachines requires `/dev/kvm`, full stop.
- Removing Docker as a host dependency — the sidecar bundle and
image-build pipeline still use Docker on Linux, same as macOS.
- Auto-installing `smolvm` or configuring KVM on the operator's
behalf. Preflight reports; the operator remediates.
- Nested-virtualization tuning for running the runner itself inside a
VM (documented as a caveat, not solved here).
## Design
### Platform detection
Reuse the existing `platform.system()` check already in
`loopback_alias.py` (`_is_macos()`). "Linux" is "not macOS" for every
branch below; no new third-platform path.
### Preflight: KVM gate (`util.smolmachines_preflight`)
After the existing `smolvm`-on-`PATH` check, add a Linux-only gate:
- `/dev/kvm` must exist → else `die()` with "enable KVM
(`kvm-intel`/`kvm-amd` kernel module)".
- `/dev/kvm` must be readable + writable by the current user
(`os.access(..., R_OK | W_OK)`) → else `die()` with "add your user
to the `kvm` group (and re-login)".
macOS is unaffected (Hypervisor.framework needs no device node).
### smolvm state-DB path (platform-aware)
`loopback_alias._SMOLVM_DB_PATH` becomes platform-derived:
- macOS: `~/Library/Application Support/smolvm/server/smolvm.db`
(unchanged).
- Linux: `$XDG_DATA_HOME/smolvm/server/smolvm.db`, defaulting to
`~/.local/share/smolvm/server/smolvm.db`.
> **Verification note:** the Linux DB location is inferred from
> smolvm's documented `~/.local/share` install layout and the XDG
> base-dir spec. It must be confirmed on a real Linux smolvm install;
> if smolvm uses a different path or schema, the fail-closed check
> below turns that into a clear `die()` at launch rather than a silent
> escape.
### TSI enforcement: cross-platform + fail-closed (`force_allowlist`)
Rework `force_allowlist(machine_name, allowed_cidrs)` to run on
**both** platforms and to fail closed:
1. Resolve the state DB; if the file is missing, `die()` (cannot
confirm enforcement → refuse to launch).
2. Read the machine's persisted row; if the row is missing, `die()`.
3. If the row's `allowed_cidrs` already equals the requested list
(e.g. a newer `smolvm` that honors `--allow-cidr` at create), do
nothing — no write.
4. Otherwise patch `allowed_cidrs` (the existing BLOB-encoded write)
and re-read.
5. If, after the patch, `allowed_cidrs` still does not equal the
requested list, `die()`.
This is robust across smolvm versions: it works whether `--allow-cidr`
is silently dropped (0.8.0) or honored (newer), and it never boots a
VM whose persisted allowlist it could not confirm. It is a strict
improvement on macOS too (today's code writes unconditionally and
never verifies).
> The persisted-row check confirms our write took, not that smolvm's
> runtime TSI enforces it. The runtime guarantee is covered by the
> sandbox-escape acceptance test; the persisted check is the cheap
> fail-closed guard at launch.
### Per-bottle loopback scoping on Linux (`allocate`)
`allocate` runs the same docker-state-driven allocation on Linux as on
macOS (`_allocate_locked`, the file lock, and `_aliases_in_use` via
`docker inspect` are all already cross-platform). The only macOS-only
step, `ensure_pool` (the `sudo ifconfig lo0 alias` dance), stays
macOS-only: on Linux `127.0.0.0/8` is already loopback, so docker can
publish bundle ports directly on `127.0.0.<N>` with no setup.
Net effect: Linux bottles get per-bottle `127.0.0.16..31/32` scoping
identical to macOS, without sudo.
### Launch flow
`launch.py` needs no structural change — `_allocate_resources` already
calls `ensure_pool()` (now a Linux no-op) then `allocate()` (now
per-bottle on Linux), and `_launch_vm` already calls
`force_allowlist()` (now active on Linux). Only the macOS-specific
docstrings are updated to describe the cross-platform behavior.
## Implementation chunks
1. **Preflight KVM gate**`util.smolmachines_preflight` +
unit tests for the missing-device and no-access branches.
2. **Platform-aware DB path + fail-closed `force_allowlist`**
`loopback_alias.py`; update/extend `TestForceAllowlist`.
3. **Cross-platform `allocate`** — drop the Linux early-return; update
`TestAllocate` / `TestAllocateLock` for the new Linux behavior.
4. **Docstring + comment cleanup** in `launch.py` and module headers.
5. **Docs** — README requirements + a Linux/NixOS host-setup section.
## Testing Strategy
- **Unit (CI, any OS):** the suite mocks `platform.system()` /
`subprocess` and patches `_SMOLVM_DB_PATH`, so the new Linux
branches are testable on the macOS/Linux CI runner without `smolvm`
or KVM. Covers: KVM preflight branches, fail-closed `force_allowlist`
(DB missing, row missing, patch-doesn't-take), per-bottle Linux
allocation + locking, platform-derived DB path.
- **Integration (Linux host with KVM — the acceptance gate):**
`tests/integration/test_sandbox_escape.py` against
`BOT_BOTTLE_BACKEND=smolmachines`. This cannot run on the macOS dev
box and must be executed on NixOS before merge.
## Open questions / verification pending
- **Confirm the Linux smolvm state-DB path and schema** on a real
install (the `~/.local/share/...` inference above).
- **Confirm whether the current smolvm Linux build still drops
`--allow-cidr` with `--from`** (the 0.8.0 bug). The fail-closed
design handles either answer, but knowing lets us drop the DB patch
if upstream fixed it.
- **Confirm docker publishing to `127.0.0.<N>` on Linux** behaves as
expected end-to-end with TSI (high confidence; standard loopback
behavior, but unverified on the target host).
## References
- PRD 0023 — smolmachines bottle backend (macOS v1).
- PRD 0022 — `test_sandbox_escape.py` acceptance gate.
- PRD 0024 — sidecar bundle image.
- smolvm: https://github.com/smol-machines/smolvm
+1
View File
@@ -4,3 +4,4 @@
pylint>=3.0.0 pylint>=3.0.0
pyright>=1.1.300 pyright>=1.1.300
coverage>=7.0.0
+7 -4
View File
@@ -92,9 +92,9 @@ class TestSandboxEscape(unittest.TestCase):
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh" "on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
) )
# Throwaway "identity file" for the git-gate's `identity` field. # Throwaway static key for the git-gate fixture. It need not
# It need not be a real SSH key: test 5 reaches gitleaks before # be a real SSH key: test 5 reaches gitleaks before any SSH
# any SSH attempt anyway. # attempt anyway.
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.") fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
os.close(fd) os.close(fd)
cls._key_path = Path(kp) cls._key_path = Path(kp)
@@ -123,7 +123,10 @@ class TestSandboxEscape(unittest.TestCase):
"git-gate": {"repos": { "git-gate": {"repos": {
"throwaway": { "throwaway": {
"url": "ssh://git@unreachable.invalid:22/throwaway.git", "url": "ssh://git@unreachable.invalid:22/throwaway.git",
"identity": str(cls._key_path), "key": {
"provider": "static",
"path": str(cls._key_path),
},
}, },
}}, }},
}, },
@@ -198,6 +198,7 @@ class TestSmolmachinesLaunch(unittest.TestCase):
# connect fails, which is the property chunk 3 will # connect fails, which is the property chunk 3 will
# preserve once egress is actually running. # preserve once egress is actually running.
r = self.bottle.exec( r = self.bottle.exec(
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 " f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 "
"2>&1 || true" "2>&1 || true"
) )
+2 -2
View File
@@ -115,8 +115,8 @@ class TestBottleIdentity(unittest.TestCase):
class TestPreserveMarker(_FakeHomeMixin, unittest.TestCase): class TestPreserveMarker(_FakeHomeMixin, unittest.TestCase):
"""The .preserve marker is how capability_apply tells cli.py's """The .preserve marker tells cli.py's session-end cleanup to keep
session-end cleanup to keep the state dir instead of removing it.""" the state dir instead of removing it."""
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
+2 -2
View File
@@ -29,8 +29,8 @@ class _FakeHomeMixin:
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase): class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
# snapshot_transcript is commented out (capability_apply is disabled); # capture_claude_session_state handles the preserve marker for
# capture_claude_session_state now only handles the preserve marker. # non-zero agent exits.
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
+3 -11
View File
@@ -108,7 +108,6 @@ def _supervise_plan() -> SupervisePlan:
return SupervisePlan( return SupervisePlan(
slug=SLUG, slug=SLUG,
queue_dir=STATE / "supervise" / "queue", queue_dir=STATE / "supervise" / "queue",
current_config_dir=STATE / "supervise" / "current-config",
internal_network=f"bot-bottle-net-{SLUG}", internal_network=f"bot-bottle-net-{SLUG}",
) )
@@ -271,18 +270,11 @@ class TestAgentAlwaysPresent(unittest.TestCase):
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"] s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
self.assertEqual(["sidecars"], s["depends_on"]) self.assertEqual(["sidecars"], s["depends_on"])
def test_agent_current_config_mount_only_with_supervise(self): def test_agent_has_no_current_config_mount_with_supervise(self):
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"] with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
self.assertTrue(any( self.assertNotIn("volumes", with_sv)
v["target"] == "/etc/bot-bottle/current-config"
for v in with_sv.get("volumes", [])
))
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"] without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
# Either no volumes key at all, or no current-config target. self.assertNotIn("volumes", without_sv)
self.assertFalse(any(
v["target"] == "/etc/bot-bottle/current-config"
for v in without_sv.get("volumes", [])
))
class TestSidecarBundleShape(unittest.TestCase): class TestSidecarBundleShape(unittest.TestCase):
@@ -75,7 +75,6 @@ def _plan(
supervise_plan = SupervisePlan( supervise_plan = SupervisePlan(
slug="demo-abc12", slug="demo-abc12",
queue_dir=Path("/tmp/queue"), queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
@@ -78,7 +78,6 @@ def _plan(
supervise_plan = SupervisePlan( supervise_plan = SupervisePlan(
slug="demo-abc12", slug="demo-abc12",
queue_dir=Path("/tmp/queue"), queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
@@ -10,6 +10,8 @@ from unittest.mock import MagicMock, patch
from bot_bottle.contrib.gitea.deploy_key_provisioner import ( from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner, GiteaDeployKeyProvisioner,
_API_TIMEOUT_SECS,
_KEYGEN_TIMEOUT_SECS,
_split_owner_repo, _split_owner_repo,
) )
from bot_bottle.deploy_key_provisioner import DeployKeyCollisionError from bot_bottle.deploy_key_provisioner import DeployKeyCollisionError
@@ -83,6 +85,25 @@ class TestCreate(unittest.TestCase):
self.assertEqual(str(fake_key_id), key_id) self.assertEqual(str(fake_key_id), key_id)
self.assertEqual(fake_private, private_bytes) self.assertEqual(fake_private, private_bytes)
def test_create_passes_timeout_to_ssh_keygen_and_urlopen(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.subprocess.run"
) as mock_run, patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen"
) as mock_urlopen, patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_bytes",
return_value=b"PRIVATE",
), patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_text",
return_value="ssh-ed25519 AAAA\n",
):
mock_urlopen.return_value = _urlopen_response({"id": 1})
provisioner.create("owner/repo", "title")
self.assertEqual(_KEYGEN_TIMEOUT_SECS, mock_run.call_args.kwargs.get("timeout"))
self.assertEqual(_API_TIMEOUT_SECS, mock_urlopen.call_args.kwargs.get("timeout"))
def test_create_raises_on_http_error(self): def test_create_raises_on_http_error(self):
provisioner = _provisioner() provisioner = _provisioner()
with patch( with patch(
@@ -139,6 +160,16 @@ class TestDelete(unittest.TestCase):
self.assertIn("/api/v1/repos/didericis/bot-bottle/keys/99", req.full_url) self.assertIn("/api/v1/repos/didericis/bot-bottle/keys/99", req.full_url)
self.assertEqual("DELETE", req.get_method()) self.assertEqual("DELETE", req.get_method())
def test_delete_passes_timeout_to_urlopen(self):
provisioner = _provisioner()
with patch(
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen"
) as mock_urlopen:
mock_urlopen.return_value = _urlopen_response({})
provisioner.delete("owner/repo", "7")
self.assertEqual(_API_TIMEOUT_SECS, mock_urlopen.call_args.kwargs.get("timeout"))
def test_delete_tolerates_404(self): def test_delete_tolerates_404(self):
provisioner = _provisioner() provisioner = _provisioner()
with patch( with patch(
+2 -2
View File
@@ -65,8 +65,8 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
) )
def test_preserve_marker_skips_dir(self): def test_preserve_marker_skips_dir(self):
# Preserve marker = capability-block or crash auto-preserve; # Preserve marker means the user explicitly wanted this dir
# the user explicitly wanted this dir kept for `resume`. # kept for `resume`.
bottle_state.write_per_bottle_dockerfile("kept-ccc", "FROM x\n") bottle_state.write_per_bottle_dockerfile("kept-ccc", "FROM x\n")
bottle_state.mark_preserved("kept-ccc") bottle_state.mark_preserved("kept-ccc")
self.assertEqual( self.assertEqual(
+71
View File
@@ -10,6 +10,7 @@ from bot_bottle.egress import (
Egress, Egress,
EgressPlan, EgressPlan,
EgressRoute, EgressRoute,
_yaml_str_escape,
egress_agent_env_entries, egress_agent_env_entries,
egress_manifest_routes, egress_manifest_routes,
egress_render_routes, egress_render_routes,
@@ -419,6 +420,76 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual(LOG_BLOCKS, cfg.log) self.assertEqual(LOG_BLOCKS, cfg.log)
class TestYamlStrEscape(unittest.TestCase):
"""_yaml_str_escape produces safe YAML double-quoted scalar content."""
def test_plain_string_unchanged(self):
self.assertEqual("api.example.com", _yaml_str_escape("api.example.com"))
def test_double_quote_escaped(self):
self.assertEqual('\\"', _yaml_str_escape('"'))
def test_backslash_escaped(self):
self.assertEqual("\\\\", _yaml_str_escape("\\"))
def test_newline_escaped(self):
self.assertEqual("\\n", _yaml_str_escape("\n"))
def test_carriage_return_escaped(self):
self.assertEqual("\\r", _yaml_str_escape("\r"))
def test_tab_escaped(self):
self.assertEqual("\\t", _yaml_str_escape("\t"))
def test_combined(self):
self.assertEqual('\\"\\n\\\\', _yaml_str_escape('"\n\\'))
class TestRenderRoutesEscaping(unittest.TestCase):
"""Stray quotes/newlines in manifest strings do not corrupt routes.yaml."""
@staticmethod
def _parsed(routes) -> list[dict]: # type: ignore
return parse_yaml_subset(egress_render_routes(routes))["routes"] # type: ignore
def test_host_with_double_quote_round_trips(self):
routes = (EgressRoute(host='bad"host.example'),)
parsed = self._parsed(routes)
self.assertEqual('bad"host.example', parsed[0]["host"])
def test_host_with_newline_round_trips(self):
routes = (EgressRoute(host="host\nextra.example"),)
parsed = self._parsed(routes)
self.assertEqual("host\nextra.example", parsed[0]["host"])
def test_auth_scheme_with_double_quote_round_trips(self):
routes = (EgressRoute(
host="api.example",
auth_scheme='Bear"er',
token_env="EGRESS_TOKEN_0",
),)
parsed = self._parsed(routes)
self.assertEqual('Bear"er', parsed[0]["auth_scheme"])
def test_path_value_with_double_quote_round_trips(self):
from bot_bottle.egress_addon_core import PathMatch, MatchEntry
routes = (EgressRoute(
host="api.example",
matches=(MatchEntry(paths=(PathMatch(type="prefix", value='/v1/"quoted"/'),)),),
),)
parsed = self._parsed(routes)
self.assertEqual('/v1/"quoted"/', parsed[0]["matches"][0]["paths"][0]["value"])
def test_header_value_with_double_quote_round_trips(self):
from bot_bottle.egress_addon_core import HeaderMatch, MatchEntry
routes = (EgressRoute(
host="api.example",
matches=(MatchEntry(headers=(HeaderMatch(name="x-h", value='val"ue'),)),),
),)
parsed = self._parsed(routes)
self.assertEqual('val"ue', parsed[0]["matches"][0]["headers"][0]["value"])
class TestResolveTokenValues(unittest.TestCase): class TestResolveTokenValues(unittest.TestCase):
def test_reads_host_env(self): def test_reads_host_env(self):
out = egress_resolve_token_values( out = egress_resolve_token_values(
+65
View File
@@ -4,6 +4,7 @@ import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from bot_bottle.git_gate import ( from bot_bottle.git_gate import (
GitGate, GitGate,
@@ -13,6 +14,8 @@ from bot_bottle.git_gate import (
git_gate_render_access_hook, git_gate_render_access_hook,
git_gate_render_entrypoint, git_gate_render_entrypoint,
git_gate_render_hook, git_gate_render_hook,
revoke_git_gate_provisioned_keys,
_resolve_identity_file,
git_gate_upstreams_for_bottle, git_gate_upstreams_for_bottle,
) )
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
@@ -328,6 +331,68 @@ class TestPrepare(unittest.TestCase):
self.assertIn("exec git daemon", content) self.assertIn("exec git daemon", content)
class TestDynamicKeyProvisioning(unittest.TestCase):
def setUp(self):
self.stage = Path(tempfile.mkdtemp())
def tearDown(self):
import shutil
shutil.rmtree(self.stage, ignore_errors=True)
def _gitea_manifest(self):
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"git-gate": {
"repos": {
"repo": {
"url": "ssh://git@gitea.example.com/org/repo.git",
"key": {
"provider": "gitea",
"forge_token_env": "GITEA_TOKEN",
},
"host_key": "ssh-ed25519 AAAA...",
},
},
}
}
},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def test_resolve_identity_file_static_uses_entry_path(self):
entry = fixture_with_git().bottles["dev"].git[0]
self.assertEqual(entry.IdentityFile, _resolve_identity_file(entry, "demo", self.stage))
def test_resolve_identity_file_gitea_provisions_key(self):
entry = self._gitea_manifest().bottles["dev"].git[0]
with patch("bot_bottle.git_gate._provision_dynamic_key", return_value="/tmp/provisioned-key") as mock_provision:
self.assertEqual("/tmp/provisioned-key", _resolve_identity_file(entry, "demo", self.stage))
mock_provision.assert_called_once()
def test_revoke_skips_non_gitea_and_missing_id_file(self):
revoke_git_gate_provisioned_keys(fixture_with_git().bottles["dev"], self.stage)
def test_revoke_calls_delete_for_gitea_entry(self):
bottle = self._gitea_manifest().bottles["dev"]
(self.stage / "repo-deploy-key-id").write_text("123\n")
with patch.dict("os.environ", {"GITEA_TOKEN": "token"}), patch(
"bot_bottle.deploy_key_provisioner.get_provisioner"
) as mock_get_provisioner:
provisioner = mock_get_provisioner.return_value
revoke_git_gate_provisioned_keys(bottle, self.stage)
mock_get_provisioner.assert_called_once()
provisioner.delete.assert_called_once_with("org/repo", "123")
def test_revoke_missing_token_raises(self):
bottle = self._gitea_manifest().bottles["dev"]
(self.stage / "repo-deploy-key-id").write_text("123\n")
with patch.dict("os.environ", {}, clear=True), self.assertRaises(RuntimeError) as cm:
revoke_git_gate_provisioned_keys(bottle, self.stage)
self.assertIn("env var is not set", str(cm.exception))
class TestShellEscaping(unittest.TestCase): class TestShellEscaping(unittest.TestCase):
"""Regression tests: all three render functions must produce syntactically """Regression tests: all three render functions must produce syntactically
valid sh code even when names and upstream URLs contain shell-special valid sh code even when names and upstream URLs contain shell-special
+107
View File
@@ -9,6 +9,7 @@ import urllib.request
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from bot_bottle.git_gate import GIT_GATE_TIMEOUT_SECS
from bot_bottle.git_http_backend import GitHttpHandler, MAX_BODY_BYTES from bot_bottle.git_http_backend import GitHttpHandler, MAX_BODY_BYTES
@@ -150,6 +151,61 @@ class TestGitHttpBackend(unittest.TestCase):
) )
self.assertEqual("git/test", env["HTTP_USER_AGENT"]) self.assertEqual("git/test", env["HTTP_USER_AGENT"])
def test_subprocess_calls_include_timeout(self):
"""Both subprocess.run calls (access-hook and git http-backend) must
pass timeout= so a hung upstream cannot wedge the sidecar."""
from http.server import ThreadingHTTPServer
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "repo.git").mkdir()
old_root = os.environ.get("GIT_PROJECT_ROOT")
os.environ["GIT_PROJECT_ROOT"] = str(root)
self.addCleanup(self._restore_env, old_root)
old_hook = os.environ.get("GIT_GATE_ACCESS_HOOK")
hook = root / "access-hook"
hook.write_text("#!/bin/sh\nexit 0\n")
hook.chmod(0o700)
os.environ["GIT_GATE_ACCESS_HOOK"] = str(hook)
self.addCleanup(self._restore_hook, old_hook)
server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
backend_response = (
b"Status: 200 OK\r\n"
b"Content-Type: application/x-git-upload-pack-result\r\n"
b"\r\n"
b"0000"
)
calls = [
subprocess.CompletedProcess(["hook"], 0, b"", b""),
subprocess.CompletedProcess(["git"], 0, backend_response, b""),
]
with mock.patch(
"bot_bottle.git_http_backend.subprocess.run",
side_effect=calls,
) as run:
req = urllib.request.Request(
f"http://127.0.0.1:{server.server_port}"
"/repo.git/git-upload-pack",
data=b"",
method="POST",
)
with urllib.request.urlopen(req, timeout=5):
pass
for call in run.call_args_list:
self.assertEqual(
GIT_GATE_TIMEOUT_SECS,
call.kwargs.get("timeout"),
f"subprocess.run call missing timeout: {call}",
)
def test_access_hook_denial_is_logged_to_stdout(self): def test_access_hook_denial_is_logged_to_stdout(self):
"""When the access-hook exits non-zero we still return 403 to the """When the access-hook exits non-zero we still return 403 to the
client, but the hook's stderr must also appear on the handler's client, but the hook's stderr must also appear on the handler's
@@ -256,6 +312,57 @@ class TestGitHttpBackend(unittest.TestCase):
os.environ["GIT_GATE_ACCESS_HOOK"] = value os.environ["GIT_GATE_ACCESS_HOOK"] = value
class TestMalformedStatusHeader(unittest.TestCase):
"""Malformed CGI Status: headers must not propagate as unhandled exceptions;
the handler should fall back to HTTP 500."""
def setUp(self):
from http.server import ThreadingHTTPServer
import tempfile
self._tmp = tempfile.mkdtemp()
os.environ["GIT_PROJECT_ROOT"] = self._tmp
self._server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
self._thread = threading.Thread(
target=self._server.serve_forever, daemon=True,
)
self._thread.start()
self._port = self._server.server_port
def tearDown(self):
self._server.shutdown()
self._server.server_close()
os.environ.pop("GIT_PROJECT_ROOT", None)
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def _get_with_backend_response(self, cgi_response: bytes) -> int:
with mock.patch(
"bot_bottle.git_http_backend.subprocess.run",
return_value=mock.Mock(returncode=0, stdout=cgi_response),
):
req = urllib.request.Request(
f"http://127.0.0.1:{self._port}/repo.git/info/refs",
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=3) as resp:
return resp.status
except urllib.error.HTTPError as e: # type: ignore
return e.code
def test_empty_status_value_returns_500(self):
status = self._get_with_backend_response(
b"Status: \r\nContent-Type: text/plain\r\n\r\n"
)
self.assertEqual(500, status)
def test_non_numeric_status_returns_500(self):
status = self._get_with_backend_response(
b"Status: bad\r\nContent-Type: text/plain\r\n\r\n"
)
self.assertEqual(500, status)
class TestContentLengthBounds(unittest.TestCase): class TestContentLengthBounds(unittest.TestCase):
"""PRD 0041: malformed or oversized Content-Length is rejected before """PRD 0041: malformed or oversized Content-Length is rejected before
git http-backend is invoked.""" git http-backend is invoked."""
+176 -3
View File
@@ -423,9 +423,182 @@ class TestExtendsErrors(unittest.TestCase):
) )
self.assertIn("extends cycle", msg) self.assertIn("extends cycle", msg)
def test_non_string_extends_dies(self): def test_non_string_non_list_extends_dies(self):
msg = _error_message(_build, child={"extends": ["base"]}) msg = _error_message(_build, child={"extends": 123})
self.assertIn("extends must be a string", msg) self.assertIn("extends must be a string or list of strings", msg)
def test_list_entry_non_string_dies(self):
msg = _error_message(_build, child={"extends": [123]})
self.assertIn("extends[0] must be a string", msg)
class TestExtendsMultiParent(unittest.TestCase):
"""extends: [p1, p2, ...] — multi-parent composition (issue #268)."""
_GIT_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/k"}}
_GIT_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/k"}}
def test_single_element_list_same_as_string(self):
m = _build(
base={"env": {"X": "1"}},
child={"extends": ["base"]},
)
self.assertEqual({"X": "1"}, dict(m.bottles["child"].env))
def test_two_parents_env_union(self):
m = _build(
p1={"env": {"A": "1"}},
p2={"env": {"B": "2"}},
child={"extends": ["p1", "p2"]},
)
self.assertEqual({"A": "1", "B": "2"}, dict(m.bottles["child"].env))
def test_two_parents_env_last_wins_on_collision(self):
m = _build(
p1={"env": {"X": "from-p1"}},
p2={"env": {"X": "from-p2"}},
child={"extends": ["p1", "p2"]},
)
self.assertEqual("from-p2", m.bottles["child"].env["X"])
def test_child_wins_over_all_parents(self):
m = _build(
p1={"env": {"X": "from-p1"}},
p2={"env": {"X": "from-p2"}},
child={"extends": ["p1", "p2"], "env": {"X": "from-child"}},
)
self.assertEqual("from-child", m.bottles["child"].env["X"])
def test_two_parents_supervise_last_wins(self):
m = _build(
p1={"supervise": False},
p2={"supervise": True},
child={"extends": ["p1", "p2"]},
)
self.assertTrue(m.bottles["child"].supervise)
def test_child_supervise_overrides_all_parents(self):
m = _build(
p1={"supervise": True},
p2={"supervise": True},
child={"extends": ["p1", "p2"], "supervise": False},
)
self.assertFalse(m.bottles["child"].supervise)
def test_two_parents_egress_routes_concatenated(self):
m = _build(
p1={"egress": {"routes": [{"host": "a.example.com"}]}},
p2={"egress": {"routes": [{"host": "b.example.com"}]}},
child={"extends": ["p1", "p2"]},
)
hosts = [r.Host for r in m.bottles["child"].egress.routes]
self.assertEqual(["a.example.com", "b.example.com"], hosts)
def test_child_egress_appends_after_combined_parents(self):
m = _build(
p1={"egress": {"routes": [{"host": "a.example.com"}]}},
p2={"egress": {"routes": [{"host": "b.example.com"}]}},
child={
"extends": ["p1", "p2"],
"egress": {"routes": [{"host": "c.example.com"}]},
},
)
hosts = [r.Host for r in m.bottles["child"].egress.routes]
self.assertEqual(["a.example.com", "b.example.com", "c.example.com"], hosts)
def test_two_parents_git_repos_union(self):
m = _build(
p1={"git-gate": {"repos": {"a": self._GIT_A}}},
p2={"git-gate": {"repos": {"b": self._GIT_B}}},
child={"extends": ["p1", "p2"]},
)
names = {e.Name for e in m.bottles["child"].git}
self.assertEqual({"a", "b"}, names)
def test_two_parents_git_same_name_later_wins_per_field(self):
# Both parents declare the same repo name. p2's `key` wins; p1's
# `host_key` is preserved because p2 doesn't override it.
p1_entry = {
"url": "ssh://git@host-a/repo.git",
"host_key": "ecdsa AAAA",
"key": {"provider": "static", "path": "/k1"},
}
p2_entry = {
"url": "ssh://git@host-a/repo.git", # required, same url
"key": {"provider": "gitea", "forge_token_env": "TOK"},
}
m = _build(
p1={"git-gate": {"repos": {"repo": p1_entry}}},
p2={"git-gate": {"repos": {"repo": p2_entry}}},
child={"extends": ["p1", "p2"]},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
e = entries[0]
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
self.assertEqual("ecdsa AAAA", e.KnownHostKey)
self.assertEqual("gitea", e.Key.provider)
def test_p1_repos_preserved_when_p2_has_none(self):
m = _build(
p1={"git-gate": {"repos": {"a": self._GIT_A}}},
p2={"env": {"X": "1"}},
child={"extends": ["p1", "p2"]},
)
names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a"], names)
def test_diamond_shared_ancestor_resolved_once(self):
# a <- b, a <- c; child extends [b, c]
# `a` must be resolved once and cached.
m = _build(
a={"env": {"FROM_A": "1"}, "supervise": False},
b={"extends": "a", "env": {"FROM_B": "1"}},
c={"extends": "a", "env": {"FROM_C": "1"}},
child={"extends": ["b", "c"]},
)
child = m.bottles["child"]
self.assertEqual("1", child.env["FROM_A"])
self.assertEqual("1", child.env["FROM_B"])
self.assertEqual("1", child.env["FROM_C"])
# supervise=False from `a` threads through both b and c; c is the
# later parent so its effective supervise (False) wins.
self.assertFalse(child.supervise)
def test_three_parents_env_fold_order(self):
m = _build(
p1={"env": {"X": "1", "A": "a"}},
p2={"env": {"X": "2", "B": "b"}},
p3={"env": {"X": "3", "C": "c"}},
child={"extends": ["p1", "p2", "p3"]},
)
env = dict(m.bottles["child"].env)
self.assertEqual("3", env["X"])
self.assertEqual("a", env["A"])
self.assertEqual("b", env["B"])
self.assertEqual("c", env["C"])
def test_undefined_bottle_in_list_dies(self):
msg = _error_message(
_build,
base={"env": {}},
child={"extends": ["base", "ghost"]},
)
self.assertIn("extends 'ghost'", msg)
self.assertIn("not defined", msg)
def test_self_reference_in_list_dies(self):
msg = _error_message(_build, child={"extends": ["child"]})
self.assertIn("extends itself", msg)
def test_cycle_through_multi_parent_edge_dies(self):
msg = _error_message(
_build,
a={"extends": ["b", "c"]},
b={},
c={"extends": "a"},
)
self.assertIn("extends cycle", msg)
class TestExtendsAvailableInBottleKeys(unittest.TestCase): class TestExtendsAvailableInBottleKeys(unittest.TestCase):
+38
View File
@@ -8,6 +8,7 @@ import unittest
from bot_bottle.git_gate import ( from bot_bottle.git_gate import (
GIT_GATE_HOSTNAME, GIT_GATE_HOSTNAME,
_gitconfig_validate_value,
git_gate_render_gitconfig, git_gate_render_gitconfig,
) )
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
@@ -90,5 +91,42 @@ class TestGitGateGitconfigRender(unittest.TestCase):
self.assertNotIn("gitea.dideric.is", out) self.assertNotIn("gitea.dideric.is", out)
class TestGitconfigValidateValue(unittest.TestCase):
"""_gitconfig_validate_value rejects values that would inject gitconfig keys."""
def test_normal_url_passes(self):
_gitconfig_validate_value("url", "ssh://git@github.com/owner/repo.git")
def test_newline_in_url_raises(self):
with self.assertRaises(ValueError):
_gitconfig_validate_value("url", "ssh://git@github.com/owner/\nrepo.git")
def test_carriage_return_in_url_raises(self):
with self.assertRaises(ValueError):
_gitconfig_validate_value("url", "ssh://git@github.com/\rrepo.git")
def test_error_message_names_field(self):
with self.assertRaises(ValueError, msg="error should name the field") as ctx:
_gitconfig_validate_value("repos['bad'].url", "ssh://host/\npath")
self.assertIn("repos['bad'].url", str(ctx.exception))
class TestGitconfigRenderRejectsNewlineInUpstream(unittest.TestCase):
"""git_gate_render_gitconfig raises on Upstream values with newlines."""
def test_newline_in_upstream_raises(self):
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {
"evil": {
"url": "ssh://git@github.com/owner/\nfake-key = injected\nrepo.git",
"key": {"provider": "static", "path": "/dev/null"},
},
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
with self.assertRaises(ValueError):
git_gate_render_gitconfig(m.bottles["dev"].git, GIT_GATE_HOSTNAME)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+119 -16
View File
@@ -8,6 +8,7 @@ inspecting running bundle containers' port bindings."""
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sqlite3 import sqlite3
import subprocess import subprocess
import tempfile import tempfile
@@ -112,9 +113,16 @@ class TestEnsurePool(unittest.TestCase):
class TestAllocate(unittest.TestCase): class TestAllocate(unittest.TestCase):
def test_returns_loopback_on_linux(self): def test_per_bottle_alias_on_linux(self):
with patch.object(loopback_alias, "_is_macos", return_value=False): # Linux gets the same per-bottle scoping as macOS (127/8 is
self.assertEqual("127.0.0.1", loopback_alias.allocate("demo")) # already loopback, so no ifconfig is needed). A fresh host
# with no running bundles allocates the first pool entry.
with tempfile.TemporaryDirectory() as tmp:
lock_path = Path(tmp) / "smolmachines.lock"
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo"))
def test_picks_lowest_unused_on_macos(self): def test_picks_lowest_unused_on_macos(self):
# No bundles running -> first pool entry. # No bundles running -> first pool entry.
@@ -166,12 +174,25 @@ class TestAllocateLock(unittest.TestCase):
self.assertIn(fcntl_mod.LOCK_EX, flock_calls) self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
def test_no_lock_on_linux(self): def test_acquires_exclusive_lock_on_linux(self):
# Linux early-returns before touching the lock file. # Linux allocates per-bottle too, so it must take the same
with patch.object(loopback_alias, "_is_macos", return_value=False), \ # lock to serialise concurrent launches.
patch.object(loopback_alias.fcntl, "flock") as flock: import fcntl as fcntl_mod
loopback_alias.allocate("demo") flock_calls: list[int] = []
flock.assert_not_called()
def record_flock(fd, op): # type: ignore
flock_calls.append(op)
with tempfile.TemporaryDirectory() as tmp:
lock_path = Path(tmp) / "smolmachines.lock"
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()), \
patch.object(loopback_alias.fcntl, "flock",
side_effect=record_flock):
loopback_alias.allocate("demo")
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
def test_sequential_allocations_with_shared_lock_are_serialised(self): def test_sequential_allocations_with_shared_lock_are_serialised(self):
# Two sequential calls share the same lock file. The second # Two sequential calls share the same lock file. The second
@@ -241,10 +262,12 @@ class TestAliasInUseDetection(unittest.TestCase):
class TestForceAllowlist(unittest.TestCase): class TestForceAllowlist(unittest.TestCase):
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`, """Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`, so
so `force_allowlist` opens the state DB directly and sets `force_allowlist` opens the state DB directly and sets the row's
the row's `allowed_cidrs` field. Round-trip tests against a `allowed_cidrs` field on both macOS and Linux. It is
real SQLite DB to lock down the BLOB encoding.""" fail-closed: it dies rather than launching a VM whose allowlist
it can't confirm. Round-trip tests against a real SQLite DB to
lock down the BLOB encoding."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.") self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
@@ -290,17 +313,67 @@ class TestForceAllowlist(unittest.TestCase):
self.assertEqual(4, cfg["cpus"]) self.assertEqual(4, cfg["cpus"])
self.assertTrue(cfg["network"]) self.assertTrue(cfg["network"])
def test_noop_on_linux(self): def test_patches_on_linux_too(self):
# force_allowlist no longer no-ops on Linux — the TSI
# allowlist must be enforced there as well.
with patch.object(loopback_alias, "_is_macos", return_value=False), \ with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db): patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"]) loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
# DB row should be untouched.
con = sqlite3.connect(str(self.db)) con = sqlite3.connect(str(self.db))
cfg = json.loads(con.execute( cfg = json.loads(con.execute(
"SELECT data FROM vms WHERE name='demo-vm'", "SELECT data FROM vms WHERE name='demo-vm'",
).fetchone()[0]) ).fetchone()[0])
con.close() con.close()
self.assertIsNone(cfg["allowed_cidrs"]) self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
def test_skips_write_when_already_matching(self):
# A newer smolvm that honors --allow-cidr at create leaves the
# row already correct; force_allowlist must not rewrite it. We
# detect a no-write by comparing the raw BLOB byte-for-byte
# (a rewrite re-serialises the JSON, changing key order/bytes
# is not guaranteed, but mtime/identity isn't observable — so
# we assert the stored bytes are exactly what we pre-seeded).
seeded = json.dumps({
"name": "demo-vm", "cpus": 4, "mem": 8192,
"network": True, "allowed_cidrs": ["127.0.0.16/32"],
}).encode()
con = sqlite3.connect(str(self.db))
con.execute(
"UPDATE vms SET data=? WHERE name='demo-vm'",
(sqlite3.Binary(seeded),),
)
con.commit()
con.close()
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
con = sqlite3.connect(str(self.db))
stored = con.execute(
"SELECT data FROM vms WHERE name='demo-vm'").fetchone()[0]
con.close()
self.assertEqual(seeded, bytes(stored))
def test_dies_when_patch_does_not_take(self):
# If the persisted allowlist still doesn't match after the
# patch (e.g. wrong schema / smolvm stores it elsewhere),
# force_allowlist must fail closed rather than boot the VM.
original = loopback_alias._read_machine_cfg
def stale_cfg(con, name):
# Always report the un-patched row so the post-write
# verification never sees the requested cidrs.
cfg = original(con, name)
cfg["allowed_cidrs"] = None
return cfg
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
patch.object(loopback_alias, "_read_machine_cfg", side_effect=stale_cfg), \
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
def test_dies_on_missing_db(self): def test_dies_on_missing_db(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \ with patch.object(loopback_alias, "_is_macos", return_value=True), \
@@ -323,5 +396,35 @@ class TestForceAllowlist(unittest.TestCase):
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"]) loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
class TestSmolvmDbPath(unittest.TestCase):
"""The smolvm state-DB path is platform-derived: Application
Support on macOS, XDG data dir on Linux."""
def test_macos_path(self):
with patch.object(loopback_alias.platform, "system", return_value="Darwin"):
p = loopback_alias._smolvm_db_path()
self.assertEqual(
("Library", "Application Support", "smolvm", "server", "smolvm.db"),
p.parts[-5:],
)
def test_linux_default_xdg_path(self):
env = {k: v for k, v in os.environ.items() if k != "XDG_DATA_HOME"}
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
patch.dict(loopback_alias.os.environ, env, clear=True):
p = loopback_alias._smolvm_db_path()
self.assertEqual(
(".local", "share", "smolvm", "server", "smolvm.db"),
p.parts[-5:],
)
def test_linux_respects_xdg_data_home(self):
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
patch.dict(loopback_alias.os.environ,
{"XDG_DATA_HOME": "/custom/data"}, clear=False):
p = loopback_alias._smolvm_db_path()
self.assertEqual(Path("/custom/data/smolvm/server/smolvm.db"), p)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -130,7 +130,6 @@ def _plan(
supervise_plan = SupervisePlan( supervise_plan = SupervisePlan(
slug="demo-abc12", slug="demo-abc12",
queue_dir=Path("/tmp/queue"), queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
) )
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
+63
View File
@@ -56,9 +56,14 @@ class TestBundleSubnet(unittest.TestCase):
class TestPreflight(unittest.TestCase): class TestPreflight(unittest.TestCase):
def test_smolvm_present_returns_none(self): def test_smolvm_present_returns_none(self):
# Pin macOS so the Linux KVM gate doesn't fire on a CI runner
# (ubuntu, no /dev/kvm) — this test isolates the PATH check.
with patch( with patch(
"bot_bottle.backend.smolmachines.util.shutil.which", "bot_bottle.backend.smolmachines.util.shutil.which",
return_value="/usr/local/bin/smolvm", return_value="/usr/local/bin/smolvm",
), patch(
"bot_bottle.backend.smolmachines.util.platform.system",
return_value="Darwin",
): ):
self.assertIsNone(smolmachines_preflight()) self.assertIsNone(smolmachines_preflight())
@@ -88,5 +93,63 @@ class TestPreflight(unittest.TestCase):
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg) self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
class TestKvmPreflight(unittest.TestCase):
"""Linux-only KVM gate: smolvm needs /dev/kvm present and
accessible. macOS skips this entirely (Hypervisor.framework)."""
def _run(self, *, system, exists, access):
with patch(
"bot_bottle.backend.smolmachines.util.shutil.which",
return_value="/usr/bin/smolvm",
), patch(
"bot_bottle.backend.smolmachines.util.platform.system",
return_value=system,
), patch(
"bot_bottle.backend.smolmachines.util.os.path.exists",
return_value=exists,
), patch(
"bot_bottle.backend.smolmachines.util.os.access",
return_value=access,
):
return smolmachines_preflight()
def test_macos_skips_kvm_check(self):
# Even with /dev/kvm absent, macOS must not run the gate.
self.assertIsNone(self._run(system="Darwin", exists=False, access=False))
def test_linux_ok_returns_none(self):
self.assertIsNone(self._run(system="Linux", exists=True, access=True))
def test_linux_missing_device_dies(self):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=False, access=False)
def test_linux_no_access_dies(self):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=True, access=False)
def test_linux_missing_device_message(self):
import io
import sys
captured = io.StringIO()
with patch.object(sys, "stderr", captured):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=False, access=False)
msg = captured.getvalue()
self.assertIn("/dev/kvm", msg)
self.assertIn("kvm-intel", msg)
def test_linux_no_access_message(self):
import io
import sys
captured = io.StringIO()
with patch.object(sys, "stderr", captured):
with self.assertRaises(SystemExit):
self._run(system="Linux", exists=True, access=False)
msg = captured.getvalue()
self.assertIn("kvm", msg)
self.assertIn("group", msg)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+13 -18
View File
@@ -16,7 +16,7 @@ from bot_bottle.supervise import (
STATUS_APPROVED, STATUS_APPROVED,
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
archive_proposal, archive_proposal,
audit_log_path, audit_log_path,
@@ -37,9 +37,9 @@ FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal( def _proposal(
tool: str = TOOL_CAPABILITY_BLOCK, tool: str = TOOL_EGRESS_ALLOW,
proposed: str = "FROM python:3.13\n", proposed: str = "routes:\n - host: example.com\n",
justification: str = "need a capability", justification: str = "need egress",
) -> Proposal: ) -> Proposal:
return Proposal.new( return Proposal.new(
bottle_slug="dev", bottle_slug="dev",
@@ -57,7 +57,7 @@ class TestProposalRoundtrip(unittest.TestCase):
self.assertTrue(p.id) self.assertTrue(p.id)
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp) self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
self.assertEqual("dev", p.bottle_slug) self.assertEqual("dev", p.bottle_slug)
self.assertEqual(TOOL_CAPABILITY_BLOCK, p.tool) self.assertEqual(TOOL_EGRESS_ALLOW, p.tool)
def test_to_from_dict_roundtrip(self): def test_to_from_dict_roundtrip(self):
p = _proposal() p = _proposal()
@@ -142,14 +142,14 @@ class TestQueueIO(unittest.TestCase):
def test_list_pending_sorted_by_arrival(self): def test_list_pending_sorted_by_arrival(self):
# Fabricate two with explicit timestamps. # Fabricate two with explicit timestamps.
a = Proposal.new( a = Proposal.new(
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_ALLOW,
proposed_file="FROM python:3.13\n", justification="early", proposed_file="routes:\n - host: early.example.com\n", justification="early",
current_file_hash="x", current_file_hash="x",
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
) )
b = Proposal.new( b = Proposal.new(
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_ALLOW,
proposed_file="FROM python:3.13\n", justification="late", proposed_file="routes:\n - host: late.example.com\n", justification="late",
current_file_hash="x", current_file_hash="x",
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
) )
@@ -319,7 +319,6 @@ class TestToolConstants(unittest.TestCase):
self.assertEqual( self.assertEqual(
( (
supervise.TOOL_EGRESS_ALLOW, supervise.TOOL_EGRESS_ALLOW,
TOOL_CAPABILITY_BLOCK,
supervise.TOOL_EGRESS_BLOCK, supervise.TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
supervise.TOOL_EGRESS_TOKEN_ALLOW, supervise.TOOL_EGRESS_TOKEN_ALLOW,
@@ -378,20 +377,16 @@ class TestSupervisePrepare(unittest.TestCase):
supervise.bot_bottle_root = fake_root # type: ignore[assignment] supervise.bot_bottle_root = fake_root # type: ignore[assignment]
return lambda: setattr(supervise, "bot_bottle_root", original) return lambda: setattr(supervise, "bot_bottle_root", original)
def test_prepare_creates_queue_and_current_config(self): def test_prepare_creates_queue(self):
plan = _StubSupervise().prepare("dev", self.stage_dir) plan = _StubSupervise().prepare("dev", self.stage_dir)
self.assertTrue(plan.queue_dir.is_dir()) self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.current_config_dir.is_dir())
self.assertEqual("dev", plan.slug) self.assertEqual("dev", plan.slug)
self.assertEqual("", plan.internal_network) self.assertEqual("", plan.internal_network)
def test_prepare_writes_no_files_to_current_config(self): def test_prepare_does_not_create_current_config_dir(self):
# dockerfile_content is no longer accepted by prepare.
# routes.yaml + allowlist live behind the
# `list-egress-routes` MCP tool (PRD 0017 chunk 3).
plan = _StubSupervise().prepare("dev", self.stage_dir) plan = _StubSupervise().prepare("dev", self.stage_dir)
files = sorted(p.name for p in plan.current_config_dir.iterdir()) self.assertFalse((self.stage_dir / "current-config").exists())
self.assertEqual([], files) self.assertFalse(hasattr(plan, "current_config_dir"))
if __name__ == "__main__": if __name__ == "__main__":
+24 -30
View File
@@ -18,7 +18,7 @@ from bot_bottle.supervise import (
STATUS_APPROVED, STATUS_APPROVED,
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_ALLOW,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
read_audit_entries, read_audit_entries,
@@ -30,9 +30,8 @@ from bot_bottle.supervise import (
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal: def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal:
payloads = { payloads = {
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
supervise.TOOL_EGRESS_ALLOW: "routes:\n - host: example.com\n", supervise.TOOL_EGRESS_ALLOW: "routes:\n - host: example.com\n",
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n", supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n", TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n",
@@ -86,14 +85,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
def test_sorted_by_arrival_across_bottles(self): def test_sorted_by_arrival_across_bottles(self):
early = Proposal.new( early = Proposal.new(
bottle_slug="api", tool=TOOL_CAPABILITY_BLOCK, bottle_slug="api", tool=TOOL_EGRESS_ALLOW,
proposed_file="FROM python:3.13\n", justification="early", proposed_file="routes:\n - host: early.example.com\n", justification="early",
current_file_hash="h", current_file_hash="h",
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
) )
late = Proposal.new( late = Proposal.new(
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_ALLOW,
proposed_file="FROM python:3.13\n", justification="late", proposed_file="routes:\n - host: late.example.com\n", justification="late",
current_file_hash="h", current_file_hash="h",
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
) )
@@ -122,7 +121,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def tearDown(self): def tearDown(self):
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK): def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
p = _proposal(tool=tool) p = _proposal(tool=tool)
qdir = supervise.queue_dir_for_slug("dev") qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
@@ -131,19 +130,29 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_approve_writes_response(self): def test_approve_writes_response(self):
qp = self._enqueue() qp = self._enqueue()
supervise_cli.approve(qp) with patch(
# capability-block is archived on approve, so the response file "bot_bottle.cli.supervise.apply_routes_change",
# moves to processed/ before the caller can read it. return_value=("routes: []\n", "routes:\n - host: example.com\n"),
resp = read_response(qp.queue_dir / "processed", qp.proposal.id) ):
supervise_cli.approve(qp)
resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual(STATUS_APPROVED, resp.status)
self.assertIsNone(resp.final_file) self.assertIsNone(resp.final_file)
def test_approve_with_final_file_marks_modified(self): def test_approve_with_final_file_marks_modified(self):
qp = self._enqueue() qp = self._enqueue()
supervise_cli.approve(qp, final_file="FROM bookworm\n", notes="tweaked") with patch(
resp = read_response(qp.queue_dir / "processed", qp.proposal.id) "bot_bottle.cli.supervise.apply_routes_change",
return_value=("routes: []\n", "routes:\n - host: edited.example.com\n"),
):
supervise_cli.approve(
qp,
final_file="routes:\n - host: edited.example.com\n",
notes="tweaked",
)
resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual(STATUS_MODIFIED, resp.status)
self.assertEqual("FROM bookworm\n", resp.final_file) self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file)
self.assertEqual("tweaked", resp.notes) self.assertEqual("tweaked", resp.notes)
def test_reject_writes_rejection(self): def test_reject_writes_rejection(self):
@@ -153,11 +162,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual(STATUS_REJECTED, resp.status)
self.assertEqual("nope", resp.notes) self.assertEqual("nope", resp.notes)
def test_no_audit_log_for_capability_block(self):
qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK)
supervise_cli.approve(qp)
self.assertEqual([], read_audit_entries("egress", "dev"))
def test_approve_egress_block_writes_audit_log(self): def test_approve_egress_block_writes_audit_log(self):
qp = self._enqueue(tool=supervise.TOOL_EGRESS_BLOCK) qp = self._enqueue(tool=supervise.TOOL_EGRESS_BLOCK)
with patch( with patch(
@@ -232,11 +236,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual(".txt", supervise_cli._suffix_for_tool(TOOL_EGRESS_TOKEN_ALLOW)) self.assertEqual(".txt", supervise_cli._suffix_for_tool(TOOL_EGRESS_TOKEN_ALLOW))
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out.
# pass
class TestEditInEditor(unittest.TestCase): class TestEditInEditor(unittest.TestCase):
def test_runs_editor_returns_edited_content(self): def test_runs_editor_returns_edited_content(self):
original_editor = os.environ.get("EDITOR") original_editor = os.environ.get("EDITOR")
@@ -281,10 +280,5 @@ class TestEditInEditor(unittest.TestCase):
os.environ["EDITOR"] = original_editor os.environ["EDITOR"] = original_editor
# class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out.
# pass
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+166 -21
View File
@@ -20,6 +20,7 @@ import supervise as _sv # noqa: E402 # type: ignore
from bot_bottle import supervise_server # noqa: E402 from bot_bottle import supervise_server # noqa: E402
from bot_bottle.supervise_server import ( from bot_bottle.supervise_server import (
ERR_INTERNAL,
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
ERR_INVALID_REQUEST, ERR_INVALID_REQUEST,
ERR_METHOD_NOT_FOUND, ERR_METHOD_NOT_FOUND,
@@ -29,7 +30,9 @@ from bot_bottle.supervise_server import (
PROPOSED_FILE_FIELD, PROPOSED_FILE_FIELD,
ServerConfig, ServerConfig,
TOOL_DEFINITIONS, TOOL_DEFINITIONS,
_RpcClientError,
_RpcError, _RpcError,
_RpcInternalError,
_response_timeout_from_env, _response_timeout_from_env,
format_response_text, format_response_text,
handle_initialize, handle_initialize,
@@ -47,15 +50,15 @@ from bot_bottle.supervise_server import (
class TestValidation(unittest.TestCase): class TestValidation(unittest.TestCase):
def test_capability_block_accepts_anything_nonempty(self):
validate_proposed_file(
_sv.TOOL_CAPABILITY_BLOCK,
"FROM python:3.13\nRUN apk add git\n",
)
def test_empty_proposed_file_rejected_for_tools_with_file_field(self): def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
with self.assertRaises(_RpcError): with self.assertRaises(_RpcError):
validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t") validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, " \n\t")
def test_capability_block_rejected_as_unknown_tool(self):
with self.assertRaises(_RpcError) as cm:
validate_proposed_file("capability-block", "FROM python:3.13\n")
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("unknown tool", cm.exception.message)
def test_egress_routes_yaml_is_validated(self): def test_egress_routes_yaml_is_validated(self):
validate_proposed_file( validate_proposed_file(
@@ -77,6 +80,65 @@ class TestValidation(unittest.TestCase):
self.assertIn("must not change egress logging", cm.exception.message) self.assertIn("must not change egress logging", cm.exception.message)
# --- Error taxonomy --------------------------------------------------------
class TestRpcErrorTaxonomy(unittest.TestCase):
def test_rpc_client_error_is_rpc_error(self):
e = _RpcClientError(ERR_INVALID_PARAMS, "bad param")
self.assertIsInstance(e, _RpcError)
self.assertEqual(ERR_INVALID_PARAMS, e.code)
self.assertEqual("bad param", e.message)
def test_rpc_internal_error_is_rpc_error(self):
e = _RpcInternalError("disk full")
self.assertIsInstance(e, _RpcError)
self.assertEqual(ERR_INTERNAL, e.code)
self.assertEqual("disk full", e.message)
def test_rpc_internal_error_preserves_cause(self):
cause = OSError("no space left on device")
try:
raise _RpcInternalError("failed to write") from cause
except _RpcInternalError as e:
self.assertIs(cause, e.__cause__)
def test_parse_error_is_client_error(self):
with self.assertRaises(_RpcClientError):
parse_jsonrpc(b"{bad json")
def test_validation_error_is_client_error(self):
with self.assertRaises(_RpcClientError):
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
def test_unknown_tool_in_tools_call_is_client_error(self):
config = ServerConfig(bottle_slug="dev", queue_dir=Path("/unused"))
with self.assertRaises(_RpcClientError) as cm:
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
def test_write_proposal_os_error_raises_internal(self):
config = ServerConfig(
bottle_slug="dev",
queue_dir=Path("/dev/null/cannot-exist"),
)
with self.assertRaises(_RpcInternalError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
},
config,
)
self.assertEqual(ERR_INTERNAL, cm.exception.code)
self.assertIsNotNone(cm.exception.__cause__)
# --- JSON-RPC parsing ------------------------------------------------------ # --- JSON-RPC parsing ------------------------------------------------------
@@ -157,7 +219,6 @@ class TestHandleToolsList(unittest.TestCase):
self.assertEqual( self.assertEqual(
sorted([ sorted([
_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_ALLOW,
_sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_EGRESS_BLOCK, _sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_LIST_EGRESS_ROUTES, _sv.TOOL_LIST_EGRESS_ROUTES,
]), ]),
@@ -233,10 +294,10 @@ class TestHandleToolsCall(unittest.TestCase):
try: try:
result = handle_tools_call( result = handle_tools_call(
{ {
"name": _sv.TOOL_CAPABILITY_BLOCK, "name": _sv.TOOL_EGRESS_BLOCK,
"arguments": { "arguments": {
"dockerfile": "FROM python:3.13\n", "routes_yaml": "routes:\n - host: example.com\n",
"justification": "need git", "justification": "need example.com",
}, },
}, },
self.config, self.config,
@@ -273,9 +334,9 @@ class TestHandleToolsCall(unittest.TestCase):
try: try:
result = handle_tools_call( result = handle_tools_call(
{ {
"name": _sv.TOOL_CAPABILITY_BLOCK, "name": _sv.TOOL_EGRESS_ALLOW,
"arguments": { "arguments": {
"dockerfile": "FROM python:3.13\n", "routes_yaml": "routes:\n - host: example.com\n",
"justification": "needed for tests", "justification": "needed for tests",
}, },
}, },
@@ -297,20 +358,52 @@ class TestHandleToolsCall(unittest.TestCase):
with self.assertRaises(_RpcError): with self.assertRaises(_RpcError):
handle_tools_call( handle_tools_call(
{ {
"name": _sv.TOOL_CAPABILITY_BLOCK, "name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"dockerfile": "FROM python:3.13\n"}, "arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
}, },
self.config, self.config,
) )
def test_missing_name_raises(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call({"arguments": {}}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_arguments_must_be_object(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": [],
},
self.config,
)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("must be an object", cm.exception.message)
def test_capability_block_call_raises_unknown_tool(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{
"name": "capability-block",
"arguments": {
"dockerfile": "FROM python:3.13\n",
"justification": "need git",
},
},
self.config,
)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("unknown tool", cm.exception.message)
def test_archives_proposal_after_response(self): def test_archives_proposal_after_response(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED) responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
try: try:
handle_tools_call( handle_tools_call(
{ {
"name": _sv.TOOL_CAPABILITY_BLOCK, "name": _sv.TOOL_EGRESS_ALLOW,
"arguments": { "arguments": {
"dockerfile": "FROM python:3.13\n", "routes_yaml": "routes:\n - host: example.com\n",
"justification": "x", "justification": "x",
}, },
}, },
@@ -332,10 +425,10 @@ class TestHandleToolsCall(unittest.TestCase):
) )
result = handle_tools_call( result = handle_tools_call(
{ {
"name": _sv.TOOL_CAPABILITY_BLOCK, "name": _sv.TOOL_EGRESS_ALLOW,
"arguments": { "arguments": {
"dockerfile": "FROM python:3.13\n", "routes_yaml": "routes:\n - host: example.com\n",
"justification": "need a capability", "justification": "need egress",
}, },
}, },
config, config,
@@ -350,6 +443,31 @@ class TestHandleToolsCall(unittest.TestCase):
class TestHandleListEgressRoutes(unittest.TestCase): class TestHandleListEgressRoutes(unittest.TestCase):
def test_success_returns_body_text(self):
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: object) -> bool:
return False
def read(self):
return b"[{\"host\": \"example.com\"}]"
class _Opener:
def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore
return _Resp()
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
result = handle_list_egress_routes(
{},
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("example.com", text)
def test_url_error_returns_tool_error(self): def test_url_error_returns_tool_error(self):
class _Opener: class _Opener:
def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore
@@ -409,6 +527,13 @@ class TestFormatResponseText(unittest.TestCase):
self.assertIn("the operator modified", text.lower()) self.assertIn("the operator modified", text.lower())
class TestFormatPendingResponseText(unittest.TestCase):
def test_formats_timeout_message(self):
text = supervise_server.format_pending_response_text(12.5)
self.assertIn("status: pending", text)
self.assertIn("12.5s", text)
# --- End-to-end HTTP sanity ------------------------------------------------ # --- End-to-end HTTP sanity ------------------------------------------------
@@ -459,7 +584,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.assertEqual("2.0", result["jsonrpc"]) self.assertEqual("2.0", result["jsonrpc"])
self.assertEqual(1, result["id"]) self.assertEqual(1, result["id"])
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index] names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
self.assertIn(_sv.TOOL_CAPABILITY_BLOCK, names) self.assertNotIn("capability-block", names)
self.assertIn(_sv.TOOL_EGRESS_ALLOW, names) self.assertIn(_sv.TOOL_EGRESS_ALLOW, names)
self.assertIn(_sv.TOOL_EGRESS_BLOCK, names) self.assertIn(_sv.TOOL_EGRESS_BLOCK, names)
@@ -469,6 +594,26 @@ class TestHttpEndToEnd(unittest.TestCase):
) )
self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index] self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index]
def test_internal_error_returns_err_internal_over_http(self):
with patch.object(
supervise_server._sv, "write_proposal",
side_effect=OSError("disk full"),
):
result = self._post_jsonrpc({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
},
})
self.assertIn("error", result)
self.assertEqual(ERR_INTERNAL, result["error"]["code"]) # type: ignore[index]
def test_health_endpoint(self): def test_health_endpoint(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
try: try: