Compare commits

..

35 Commits

Author SHA1 Message Date
didericis c3f55357cc test(smolmachines): verify Linux sandbox escape gate + fix platform check
lint / lint (push) Successful in 1m56s
Empirical verification on NixOS/Linux with /dev/kvm + smolvm 1.4.7:

  1. DB path confirmed: ~/.local/share/smolvm/server/smolvm.db exists,
     schema matches (vms table, data BLOB with allowed_cidrs JSON field).

  2. smolvm 1.4.7 still silently drops --allow-cidr with --from
     (allowed_cidrs=None in DB after create). force_allowlist patch
     is still necessary and correct.

  3. All 5 sandbox-escape attacks blocked with BOT_BOTTLE_BACKEND=smolmachines
     on Linux: hostname/IP allowlist, HTTP DLP, DNS exfil, git-gate gitleaks.

Changes:
- test_sandbox_escape: remove darwin-only guard; allow linux too
- contrib/claude/Dockerfile: add dnsutils (dig) for attack 4 DNS test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 00:49:04 -04:00
didericis 36e62cd7e8 test: add unit tests to pass diff-coverage gate (91%)
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m4s
lint / lint (push) Successful in 1m56s
prd-number / assign-numbers (push) Successful in 19s
test / unit (push) Successful in 1m0s
test / integration (push) Successful in 19s
test / coverage (push) Successful in 1m5s
Update Quality Badges / update-badges (push) Failing after 59s
Cover the new production lines added on this branch:

- cli/__init__: MissingEnvVarError maps to return 1
- cli/start: no-agents-defined path, prepare_with_preflight calls
  render_preflight, _text_render_preflight backend name output
- env: resolve_env raises MissingEnvVarError for missing interpolated var
- smolmachines/launch: _proxy_host Linux/non-Linux branches,
  _discover_urls git-gate host and supervise URL stamping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 00:19:24 -04:00
didericis 70e58f1333 test: fix test_cache_miss and remove unused re import
lint / lint (push) Successful in 2m8s
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Failing after 1m6s
- test_smolmachines_launch_image: save mock now touches the tarball path
  so tarball.chmod(0o644) doesn't fail with FileNotFoundError
- test_smolmachines_launch (integration): remove unused `re` import
  (pyright reportUnusedImport)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 00:03:00 -04:00
didericis 3703b6f59f test: fix unit test failures after main rebase
lint / lint (push) Failing after 1m58s
test / unit (pull_request) Failing after 57s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 56s
- test_egress: expect MissingEnvVarError instead of Die for missing/empty token refs
- test_git_gate, test_git_gate_render_provision: expect MissingEnvVarError instead of RuntimeError for missing forge token
- test_smolmachines_provision: handle 2 exec calls (mkdir -p + chmod/update-ca-certificates) in provision_ca
- test_smolmachines_smolvm: update machine create/delete argv assertions to --name flag form (smolvm 1.4.7+)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 23:46:47 -04:00
didericis afd943c4f9 fix(smolmachines): fix CA provisioning and git-http startup on Linux
lint / lint (push) Failing after 2m2s
test / unit (pull_request) Failing after 56s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Failing after 53s
Two fixes for the Linux smolmachines backend:

- provision_ca: add mkdir -p before cp_in so the
  /usr/local/share/ca-certificates/ directory exists in the smolvm
  guest even when smolvm pack omits it (empty dir on Linux).
- git_http_backend: the fallback import in the except ImportError
  block tried `from git_gate import ...`, but git_gate.py is not
  deployed to the sidecar container and has its own package-relative
  imports; use the literal value 15 directly instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:06:28 -04:00
didericis 8f31741c9f refactor(errors): introduce MissingEnvVarError for missing host env vars
Replace die()/RuntimeError at the point of detection with a typed
MissingEnvVarError, and catch it in the CLI dispatcher alongside
ManifestError. Library code stays free of stderr output; the CLI
renders all user-facing error lines in one place.

Sites updated: egress.egress_resolve_token_values (unset/empty token),
git_gate._provision_dynamic_key and revoke_git_gate_provisioned_keys
(missing forge_token_env), env.resolve_env (unset interpolated var).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:05:49 -04:00
didericis 5bfe45bc1d fix(git-gate): use die() instead of RuntimeError for missing forge token env
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:05:03 -04:00
didericis 7aca7d5289 feat(start): show backend name in preflight output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:01:58 -04:00
didericis f6d4eb9e3c fix(start): print error when no agents are defined
Previously start silently exited 0 when the manifest had no agents,
leaving the user with no feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:01:58 -04:00
didericis c5b9f05467 fix(smolmachines): use bridge gateway as TSI proxy host on Linux
On Linux the guest kernel's LOCAL routing table routes all
127.0.0.0/8 to the guest's own loopback interface (priority 0,
checked before any main-table route), so TSI never sees
connections to the per-bottle loopback alias — the fix_guest_
loopback_routing approach confirmed this at the kernel level.

Use the per-bottle docker bridge gateway (192.168.N.1) instead.
It is not a loopback address, so the guest routes it via eth0
and TSI intercepts it normally.  The TSI allowlist remains a
/32 that is distinct from the container IP (192.168.N.2), so
direct bypass to egress:9099 is still blocked by TSI.

Changes:
- Add _proxy_host() helper: returns bundle_gateway on Linux,
  loopback alias on macOS
- Thread proxy_host through _start_bundle, _discover_urls,
  _launch_vm, and _bundle_launch_spec (publish_host_ip)
- Remove _fix_guest_loopback_routing (no longer needed)
- Relax proxy-URL assertion in the integration test to accept
  any http://IP:port (with a comment explaining the difference)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:01:58 -04:00
didericis-codex 09a2329f31 test(smolmachines): annotate pyright test helpers 2026-07-08 22:01:35 -04:00
didericis 03c89dae81 feat(smolmachines): run backend on Linux
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-07-08 22:01:35 -04:00
didericis dd7232f368 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-07-08 22:01:35 -04:00
didericis 76fdefded3 fix: repair codex resume paths
lint / lint (push) Successful in 1m55s
test / unit (push) Successful in 57s
test / integration (push) Successful in 19s
test / coverage (push) Successful in 1m6s
Update Quality Badges / update-badges (push) Successful in 1m1s
2026-07-08 12:38:15 -04:00
didericis 1085a2280d fix(macos_container): mount supervise db's parent dir, not the file
lint / lint (push) Successful in 1m55s
test / unit (push) Successful in 59s
test / integration (push) Successful in 19s
test / coverage (push) Successful in 1m8s
Update Quality Badges / update-badges (push) Successful in 57s
Apple's `container run --mount type=bind` only accepts directory
sources — a file source fails with "path '<file>' is not a
directory". Move the sqlite db into its own ~/.bot-bottle/db/
subdirectory so it can be bind-mounted the same way the CA/routes
mounts already are, without exposing the rest of ~/.bot-bottle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 15:48:36 -04:00
github-actions[bot] c97f4ecb8d ci(prd): assign sequential numbers to new PRDs 2026-07-06 19:29:43 +00:00
didericis-claude 142974a4b8 refactor: rename check_migrations → is_migrated
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m6s
lint / lint (push) Successful in 1m55s
prd-number / assign-numbers (push) Successful in 16s
test / unit (push) Successful in 54s
test / integration (push) Successful in 19s
test / coverage (push) Successful in 1m6s
Update Quality Badges / update-badges (push) Successful in 1m2s
Boolean-returning methods should read as predicates.
Renames DbStore.check_migrations, StoreManager.check_migrations,
and the test patch accordingly.
2026-07-06 19:19:49 +00:00
didericis-claude 041d9bbbf5 refactor: inline TableMigrations into store constructors
lint / lint (push) Successful in 1m59s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m4s
Move _MIGRATIONS from module scope into AuditStore.__init__ and
QueueStore.__init__, treating table schema as a hidden implementation
detail of each store class.
2026-07-06 19:12:45 +00:00
didericis-claude 9fb83ef1b0 refactor: rename _supervise_types → supervise_types (treat as public)
lint / lint (push) Successful in 2m1s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 18:53:53 +00:00
didericis-claude e7e8c7fdb4 refactor: extract supervise types to _supervise_types, eliminate get_supervise_mod()
Proposal, Response, AuditEntry, host_db_path, bot_bottle_root, and their
shared constants/helpers are moved to a new _supervise_types.py module.
queue_store and audit_store now import these directly instead of deferring
through get_supervise_mod() at call time.

host_db_path() in _supervise_types uses sys.modules[__name__].bot_bottle_root()
so monkey-patches on _supervise_types.bot_bottle_root propagate through it.
supervise.host_db_path() is re-defined to call bot_bottle_root() through
supervise's own globals, preserving patchability for callers that go through
the supervise module.

Test fixtures that create stores without an explicit db_path now patch
_sv_types.bot_bottle_root alongside supervise.bot_bottle_root. The server
test patches both flat and package _supervise_types since handle_tools_call
runs in the package context while the responder thread uses the flat module.

Dockerfile.sidecars gets a COPY for _supervise_types.py so the sidecar
bundle retains its flat import chain.

Follow-up to issuecomment-2872 on PR #320.
2026-07-06 18:53:25 +00:00
didericis-claude ed8fbfdfa8 feat: add StoreManager singleton for centralized store migration
lint / lint (push) Successful in 1m59s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Failing after 1m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 18:52:01 +00:00
didericis-claude 5fcca2060f feat(db_store): decouple migration from store construction
lint / lint (push) Failing after 2m4s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 1m9s
Remove auto-migration from DbStore.__init__; add explicit check_migrations()
and migrate() methods. The CLI now checks both stores on every startup and
prompts the user to confirm before migrating. supervise.prepare() calls
.migrate() directly now that __init__ no longer does it implicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 17:16:18 +00:00
didericis-claude a4d82e5ff2 refactor: extract TableMigrations and DbStore base class
lint / lint (push) Successful in 2m0s
test / unit (pull_request) Successful in 1m4s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Successful in 1m9s
Adds bot_bottle/migrations.py (TableMigrations) and bot_bottle/db_store.py
(DbStore) per PR review. Both stores now inherit from DbStore and hold a
TableMigrations instance instead of duplicating schema-version logic inline.
2026-07-02 21:04:34 +00:00
didericis-claude e8e4f6f7c7 refactor: address PR review — rename, move helpers, add migration runner
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m5s
Per review #320 comments:

- Rename _sv() → get_supervise_mod() in both store files (review 206/211)
- Move _audit_entry_from_row onto AuditStore as _row_to_entry static method
  (review 208); move _proposal/_response_from_row onto QueueStore (review 211)
- Remove _host_db_path() free function; inline into __init__ (review 209/211)
- Add stdlib migration runner using a shared schema_versions table; each store
  tracks its own version under a module key so they can coexist in the same DB
  without clobbering a shared PRAGMA user_version (reviews 210/212/213)
- PRD: add goal 6 (migration runner), narrow non-goal to third-party ORM only
2026-07-02 03:27:02 +00:00
didericis-claude 5f0fc0d540 test: cover QueueStore/AuditStore guard branches and supervise bundle spec
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 52s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Successful in 1m2s
Add 8 tests covering the branches that were keeping diff-coverage below
90%: explicit db_path constructor arg, early-return guards when the DB
file is absent, _chmod OSError swallowing in both store classes, and the
supervise volume/env/daemon path in _bundle_launch_spec.

Diff-coverage rises from 89.2% to 94.6% (176/186 changed lines).
2026-07-02 02:59:26 +00:00
didericis-claude 244ad6a914 refactor: extract QueueStore and AuditStore to their own modules
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Failing after 59s
Moves _QueueStore → bot_bottle/queue_store.py (public QueueStore) and
_AuditStore → bot_bottle/audit_store.py (public AuditStore). Removes
the public queue_db_path() function; QueueStore resolves the DB path
via host_db_path() on the host, or via the SUPERVISE_DB_PATH env var
in the sidecar container (internal mechanism, not public API).

Adds queue_store.py and audit_store.py to Dockerfile.sidecars so the
sidecar bundle picks them up. Updates __all__ in supervise.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 21:45:08 +00:00
didericis-codex 29904609da fix(supervise): remove queue directory from db-backed flow
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m10s
2026-07-01 19:50:38 +00:00
didericis-codex 3067b067d2 fix(supervise): store queue rows in host sqlite db
lint / lint (push) Successful in 2m5s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m2s
2026-07-01 19:33:43 +00:00
didericis-codex 212551df9a docs(prd): activate sqlite local storage
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m6s
2026-07-01 16:57:54 +00:00
didericis-codex f1b8bbdfa1 test(supervise): update edge cases for sqlite storage
lint / lint (push) Successful in 1m55s
test / unit (pull_request) Successful in 53s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m10s
2026-07-01 16:57:45 +00:00
didericis-codex 08918f9a8a feat(supervise): store queue and audit data in sqlite
lint / lint (push) Failing after 1m53s
test / unit (pull_request) Failing after 45s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Failing after 50s
2026-07-01 16:56:23 +00:00
didericis-codex 9af02831ea docs(prd): add sqlite local storage plan 2026-07-01 16:53:23 +00:00
Quality Badge Bot 5970b785aa chore: update quality badges
- Coverage: 83%
- Core coverage: 95%

[skip ci]
2026-07-01 16:51:08 +00:00
didericis 2f5cf81cf5 fix(git-gate): defer dynamic key provisioning
lint / lint (push) Successful in 1m59s
test / unit (push) Successful in 49s
test / integration (push) Successful in 23s
test / coverage (push) Successful in 1m0s
Update Quality Badges / update-badges (push) Successful in 53s
2026-07-01 12:45:46 -04:00
didericis 4a1e667306 fix(git-gate): inline GIT_GATE_TIMEOUT_SECS to fix git-http ImportError
lint / lint (push) Successful in 1m56s
test / unit (push) Successful in 48s
test / integration (push) Successful in 20s
test / coverage (push) Successful in 1m2s
Update Quality Badges / update-badges (push) Successful in 52s
git_http_backend.py is copied flat into the sidecar bundle image as a
standalone script, not as part of the bot_bottle package, and
git_gate.py/git_gate_render.py are never copied in. Its relative
import of GIT_GATE_TIMEOUT_SECS crashed the git-http daemon (port
9420) on every startup, silently leaving the smart-HTTP git-gate
transport down while the other sidecar daemons stayed up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 11:53:26 -04:00
72 changed files with 2435 additions and 2222 deletions
+8 -2
View File
@@ -18,7 +18,7 @@
# /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime
# /run/supervise/queue/ bind-mounted at run time
# /run/supervise/bot-bottle.db bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
#
# Exposed ports inside the container:
@@ -66,6 +66,12 @@ COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/migrations.py /app/migrations.py
COPY bot_bottle/db_store.py /app/db_store.py
COPY bot_bottle/supervise_types.py /app/supervise_types.py
COPY bot_bottle/queue_store.py /app/queue_store.py
COPY bot_bottle/audit_store.py /app/audit_store.py
COPY bot_bottle/store_manager.py /app/store_manager.py
COPY bot_bottle/supervise.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
@@ -81,7 +87,7 @@ RUN mkdir -p \
/etc/git-gate \
/git-gate/creds \
/git \
/run/supervise/queue \
/run/supervise \
/home/mitmproxy/.mitmproxy
# Documentation only — the compose renderer publishes whichever
+20 -4
View File
@@ -5,8 +5,8 @@
# bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-84%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-96%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
[![coverage](https://img.shields.io/badge/coverage-83%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-95%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
**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 +25,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.
- **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.
- **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`.
## Architecture
@@ -71,10 +71,26 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
## 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.
### 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
./cli.py start <agent> # builds the image on first run, drops you into claude
```
+4
View File
@@ -227,6 +227,10 @@ class AgentProvider(ABC):
from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
from .log import die
cert_host_path, label = select_ca_cert(plan.egress_plan)
# Ensure the target directory exists. smolvm's pack step may not
# preserve the empty /usr/local/share/ca-certificates/ directory
# on Linux; mkdir -p is idempotent and safe for all backends.
bottle.exec("mkdir -p /usr/local/share/ca-certificates", user="root")
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
r = bottle.exec(
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
+91
View File
@@ -0,0 +1,91 @@
"""SQLite-backed audit store for supervise (PRD 0013)."""
from __future__ import annotations
import sqlite3
from pathlib import Path
try:
from .supervise_types import AuditEntry, host_db_path
from .db_store import DbStore
from .migrations import TableMigrations
except ImportError:
from supervise_types import AuditEntry, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
class AuditStore(DbStore):
"""SQLite-backed persistent store for supervise audit entries."""
def __init__(self, db_path: Path | None = None) -> None:
# One entry per schema version: migrations[0] brings a fresh DB to
# version 1, [1] to version 2, etc. Add new entries at the end; never
# edit existing ones.
migrations = TableMigrations("audit_store", [
# v1 — initial schema
"""
CREATE TABLE IF NOT EXISTS supervise_audit_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
bottle_slug TEXT NOT NULL,
component TEXT NOT NULL,
operator_action TEXT NOT NULL,
operator_notes TEXT NOT NULL,
justification TEXT NOT NULL,
diff TEXT NOT NULL
)
""",
])
super().__init__(db_path or host_db_path(), migrations)
def write_audit_entry(self, entry: AuditEntry) -> Path:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO supervise_audit_entries (
timestamp, bottle_slug, component, operator_action,
operator_notes, justification, diff
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
entry.timestamp,
entry.bottle_slug,
entry.component,
entry.operator_action,
entry.operator_notes,
entry.justification,
entry.diff,
),
)
self._chmod()
return self.db_path
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
if not self.db_path.is_file():
return []
with self._connect() as conn:
rows = conn.execute(
"""
SELECT * FROM supervise_audit_entries
WHERE component = ? AND bottle_slug = ?
ORDER BY id
""",
(component, slug),
).fetchall()
return [self._row_to_entry(row) for row in rows]
@staticmethod
def _row_to_entry(row: sqlite3.Row) -> AuditEntry:
return AuditEntry(
timestamp=row["timestamp"],
bottle_slug=row["bottle_slug"],
component=row["component"],
operator_action=row["operator_action"],
operator_notes=row["operator_notes"],
justification=row["justification"],
diff=row["diff"],
)
__all__ = ["AuditStore"]
+4 -5
View File
@@ -34,7 +34,7 @@ from ...egress import (
from ...git_gate import GIT_GATE_HOSTNAME
from ...log import die, warn
from ...supervise import (
QUEUE_DIR_IN_CONTAINER,
DB_PATH_IN_CONTAINER,
SUPERVISE_HOSTNAME,
SUPERVISE_PORT,
)
@@ -163,16 +163,15 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
if sp is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append({
"type": "bind",
"source": str(sp.queue_dir),
"target": QUEUE_DIR_IN_CONTAINER,
"source": str(sp.db_path),
"target": DB_PATH_IN_CONTAINER,
"read_only": False,
})
internal_aliases = [EGRESS_HOSTNAME]
if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME)
+9 -1
View File
@@ -37,7 +37,10 @@ from pathlib import Path
from typing import Callable, Generator
from ...egress import egress_resolve_token_values
from ...git_gate import revoke_git_gate_provisioned_keys
from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...log import info, warn
from . import network as network_mod
from . import util as docker_mod
@@ -118,6 +121,11 @@ def launch(
git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle,
git_gate_plan,
git_gate_state_dir(plan.slug),
)
git_gate_plan = dataclasses.replace(
git_gate_plan,
internal_network=internal_network,
+30 -4
View File
@@ -28,9 +28,12 @@ from ...egress import (
egress_resolve_token_values,
egress_sidecar_env_entries,
)
from ...git_gate import revoke_git_gate_provisioned_keys
from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...log import die, info, warn
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
from ..docker.git_gate import (
@@ -98,6 +101,8 @@ def launch(
egress_network = egress_network_name(plan.slug)
_create_networks(internal_network, egress_network, stack)
plan = _provision_git_gate_keys(plan)
sidecar_name = sidecar_container_name(plan.slug)
container_mod.force_remove_container(sidecar_name)
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
@@ -241,6 +246,19 @@ def _stamp_agent_urls(
)
def _provision_git_gate_keys(
plan: MacosContainerBottlePlan,
) -> MacosContainerBottlePlan:
if not plan.git_gate_plan.upstreams:
return plan
git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle,
plan.git_gate_plan,
git_gate_state_dir(plan.slug),
)
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
gp = plan.git_gate_plan
if not gp.upstreams:
@@ -361,7 +379,7 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
if plan.supervise_plan is not None:
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
return tuple(env)
@@ -387,7 +405,15 @@ def _sidecar_mounts(
sp = plan.supervise_plan
if sp is not None:
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# `container run --mount type=bind` only accepts directory
# sources (a file source fails with "is not a directory") —
# mount db_path's dedicated parent dir instead of the file
# itself, same as the CA/routes mounts above.
mounts.append((
str(sp.db_path.parent),
str(Path(DB_PATH_IN_CONTAINER).parent),
False,
))
return tuple(mounts)
+14
View File
@@ -47,10 +47,24 @@ _HOME_FOR = {
"root": "/root",
}
_DEFAULT_PATH_FOR = {
# Committed smolmachine snapshots are rebuilt from a rootfs tarball and
# lose Docker image ENV metadata. Restore the provider CLI path here so
# resumed Codex bottles can still find the per-user install.
"node": (
"/home/node/.local/bin:"
"/home/node/.codex/packages/standalone/current/bin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
"root": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]:
home = _HOME_FOR.get(user, f"/home/{user}")
out = [f"HOME={home}", f"USER={user}"]
if "PATH" not in env:
out.append(f"PATH={_DEFAULT_PATH_FOR.get(user, _DEFAULT_PATH_FOR['root'])}")
for k, v in env.items():
out.append(f"{k}={v}")
return out
+99 -42
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import dataclasses
import os
import platform
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator
@@ -27,7 +28,7 @@ from ...egress import (
egress_resolve_token_values,
egress_sidecar_env_entries,
)
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde
from ..docker import util as docker_mod
from ..docker.egress import (
@@ -41,7 +42,10 @@ from ..docker.git_gate import (
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ...git_gate import revoke_git_gate_provisioned_keys
from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...log import info, warn
from ...bottle_state import (
egress_state_dir,
@@ -88,12 +92,13 @@ def launch(
try:
loopback_ip, network = _allocate_resources(plan, stack)
plan = _mint_certs(plan)
plan = _start_bundle(plan, network, loopback_ip, stack)
plan = _discover_urls(plan, loopback_ip)
proxy_host = _proxy_host(plan, loopback_ip)
plan = _start_bundle(plan, network, proxy_host, stack)
plan = _discover_urls(plan, proxy_host)
agent_from_path = _agent_from_path(plan)
_launch_vm(plan, agent_from_path, loopback_ip, stack)
_launch_vm(plan, agent_from_path, proxy_host, stack)
_init_vm(plan)
bottle = SmolmachinesBottle(
@@ -141,10 +146,12 @@ def _allocate_resources(
) -> tuple[str, str]:
"""Reserve a loopback alias and create the per-bottle docker bridge.
macOS only routes 127.0.0.1 by default; the per-bottle alias
scopes TSI's allowlist to this bottle's published ports so the
agent can't reach other bottles' or host services' ports on
loopback. No-op on Linux."""
The per-bottle alias scopes TSI's allowlist to this bottle's
published ports so the agent can't reach other bottles' or host
services' ports on loopback. On macOS `ensure_pool` first
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_ip = _loopback.allocate(plan.slug)
network = _bundle.bundle_network_name(plan.slug)
@@ -169,12 +176,13 @@ def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
def _start_bundle(
plan: SmolmachinesBottlePlan,
network: str,
loopback_ip: str,
proxy_host: str,
stack: ExitStack,
) -> SmolmachinesBottlePlan:
"""Build the BundleLaunchSpec, resolve token env, start the
sidecar bundle container, and register teardown."""
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
plan = _provision_git_gate_keys(plan)
bundle_spec = _bundle_launch_spec(plan, network, proxy_host)
token_env = _resolve_token_env(plan, dict(os.environ))
_bundle.ensure_bundle_image(bundle_spec.image)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
@@ -182,41 +190,55 @@ def _start_bundle(
return plan
def _provision_git_gate_keys(
plan: SmolmachinesBottlePlan,
) -> SmolmachinesBottlePlan:
if not plan.git_gate_plan.upstreams:
return plan
git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle,
plan.git_gate_plan,
git_gate_state_dir(plan.slug),
)
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
def _discover_urls(
plan: SmolmachinesBottlePlan,
loopback_ip: str,
proxy_host: str,
) -> SmolmachinesBottlePlan:
"""Discover host-side ports for published container ports and
return the plan with URLs + guest_env stamped in.
Docker container IPs (192.168.x.x in the daemon's bridge)
aren't reachable from the smolvm guest on macOS — TSI uses
macOS networking, and macOS sees the daemon's bridge via the
published-port loopback forward only.
`proxy_host` is the host IP that both TSI's allowlist and
docker's port-forward bindings are keyed to. On macOS it is the
per-bottle loopback alias; on Linux it is the per-bottle bridge
gateway (see `_proxy_host`). The agent dials the published port
on this IP for all bundle-hosted services.
NO_PROXY includes the per-bottle loopback alias so the
supervise + git-gate URLs bypass HTTPS_PROXY."""
NO_PROXY includes `proxy_host` so supervise + git-gate URLs
bypass HTTPS_PROXY."""
agent_facing_host_port = _bundle.bundle_host_port(
plan.slug, _EGRESS_PORT, host_ip=loopback_ip,
plan.slug, _EGRESS_PORT, host_ip=proxy_host,
)
agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
agent_proxy_url = f"http://{proxy_host}:{agent_facing_host_port}"
agent_git_gate_host = ""
if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port(
plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip,
plan.slug, _GIT_HTTP_PORT, host_ip=proxy_host,
)
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_git_gate_host = f"{proxy_host}:{git_gate_host_port}"
agent_supervise_url = ""
if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
plan.slug, _SUPERVISE_PORT, host_ip=proxy_host,
)
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
agent_supervise_url = f"http://{proxy_host}:{supervise_host_port}/"
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
no_proxy = f"{existing_no_proxy},{loopback_ip}"
no_proxy = f"{existing_no_proxy},{proxy_host}"
guest_env = {
**plan.guest_env,
"HTTPS_PROXY": agent_proxy_url,
@@ -246,27 +268,32 @@ def _discover_urls(
def _launch_vm(
plan: SmolmachinesBottlePlan,
agent_from_path: Path,
loopback_ip: str,
proxy_host: str,
stack: ExitStack,
) -> None:
"""Create, patch, and start the smolvm VM; register teardown.
--allow-cidr is the per-bottle loopback alias so the guest can
only reach this bottle's bundle ports. force_allowlist patches
smolvm 0.8.0's silent-drop of --allow-cidr when combined with
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
--allow-cidr is `proxy_host/32` — the per-bottle loopback alias
on macOS or the bridge gateway on Linux (see `_proxy_host`). This
ensures the guest can only reach bundle ports published on that IP,
not the container IP directly. force_allowlist confirms the
allowlist persisted (patching smolvm 0.8.0's silent-drop of
--allow-cidr when combined with --from) and fails closed if it
can't. Smolfile isn't usable here — smolvm 0.8.0 makes --from
and --smolfile mutually exclusive."""
tsi_cidr = f"{proxy_host}/32"
_smolvm.machine_create(
plan.machine_name,
from_path=agent_from_path,
allow_cidrs=[f"{loopback_ip}/32"],
allow_cidrs=[tsi_cidr],
env=plan.guest_env,
)
stack.callback(_smolvm.machine_delete, plan.machine_name)
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
# when combined with `--from`. Patch the persisted state DB
# before start so the booted VM's TSI actually enforces.
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
# Confirm the booted VM's TSI allowlist will actually enforce the
# /32 before start (smolvm 0.8.0 silently drops `--allow-cidr`
# 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, [tsi_cidr])
_smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name)
@@ -275,7 +302,9 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
"""Repair filesystem ownership and wait for exec channel readiness.
Ownership repair: smolvm's pack process remaps files to the host
invoker's uid (501 on macOS). /home/node must be node:node so
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
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
@@ -298,8 +327,29 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
_smolvm.wait_exec_ready(plan.machine_name)
def _proxy_host(plan: SmolmachinesBottlePlan, loopback_ip: str) -> str:
"""Return the host IP for TSI's allowlist and docker port-forward bindings.
On macOS, the per-bottle loopback alias (e.g. ``127.0.0.16``) works
because macOS's network stack lets TSI intercept 127.x.x.x connects
from the guest before they reach the host's own loopback.
On Linux, the guest kernel's LOCAL routing table routes all
``127.0.0.0/8`` to the guest's own loopback — those packets never
reach eth0 and TSI never sees them. Using the per-bottle bridge
gateway (e.g. ``192.168.N.1``) instead sidesteps the problem: it
is not a loopback address, so the guest routes it via eth0 and TSI
intercepts it normally. The TSI allowlist is ``gateway/32``, which
is distinct from the container IP (``192.168.N.2``), so the agent
still can't reach the egress daemon directly — TSI blocks any
connection to the container IP that isn't via the published port."""
if platform.system() == "Linux":
return plan.bundle_gateway
return loopback_ip
def _bundle_launch_spec(
plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
plan: SmolmachinesBottlePlan, network: str, proxy_host: str,
) -> _bundle.BundleLaunchSpec:
"""Build a BundleLaunchSpec from the resolved inner Plans.
@@ -352,14 +402,15 @@ def _bundle_launch_spec(
daemons.append("supervise")
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI +
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
# published on `proxy_host` so the TSI allowlist and the docker
# port-forward bindings point at the same IP. Egress is always
# the agent's HTTP/HTTPS proxy.
ports_to_publish: list[int] = [_EGRESS_PORT]
if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT)
@@ -376,7 +427,7 @@ def _bundle_launch_spec(
environment=tuple(env),
volumes=tuple(volumes),
ports_to_publish=tuple(ports_to_publish),
publish_host_ip=loopback_ip,
publish_host_ip=proxy_host,
)
@@ -450,6 +501,12 @@ def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
docker_mod.save(image_ref, str(tarball))
# On Linux, `docker save -o` writes the tarball with owner-only
# permissions (mode 600). The crane push container runs as UID
# 65532 (distroless nonroot) and can't read it through a bind
# mount unless world-read is set. The tarball is temporary and
# lives in ~/.cache, so 644 is safe.
tarball.chmod(0o644)
try:
with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
@@ -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
prompt.
Linux native daemons share the host's network namespace; the
whole `127.0.0.0/8` is reachable by default and aliases are
unnecessary. The pool logic detects native-Linux and skips sudo
entirely; the DB patch is also gated on macOS.
On Linux the whole `127.0.0.0/8` is already routed to `lo`, so
docker can publish a bundle's ports directly on `127.0.0.<N>`
with no `ifconfig`/sudo step. `ensure_pool` is therefore a no-op
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
containers' published host IPs — each bottle's bundle owns the
@@ -47,6 +50,7 @@ from __future__ import annotations
import fcntl
import json
import os
import platform
import re
import sqlite3
@@ -57,13 +61,19 @@ from typing import Iterable
from ...log import die, info
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
# table holds one JSON BLOB per machine. The Linux path is
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
# we hard-code this. If the file moves under us we'll see a
# clear FileNotFoundError; not worth defensive cross-platform
# detection until the backend actually needs Linux.
_SMOLVM_DB_PATH = (
def _smolvm_db_path() -> Path:
"""smolvm's persistent VM state — a SQLite DB whose `vms` table
holds one JSON BLOB per machine. macOS stores it under
`Application Support`; Linux follows the XDG base-dir spec
(`$XDG_DATA_HOME`, default `~/.local/share`).
NOTE: the Linux location is inferred from smolvm's documented
`~/.local/share` install layout and must be confirmed against a
real Linux smolvm install. If it's wrong, `force_allowlist`'s
fail-closed check turns it into a clear launch-time error rather
than a silent escape."""
if platform.system() == "Darwin":
return (
Path.home()
/ "Library"
/ "Application Support"
@@ -71,6 +81,14 @@ _SMOLVM_DB_PATH = (
/ "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
@@ -131,30 +149,64 @@ def ensure_pool() -> None:
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
"""Patch smolvm's persistent VM-state DB to set the machine's
`allowed_cidrs` to the given list. Workaround for smolvm
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
"""Ensure the machine's persisted TSI allowlist equals
`allowed_cidrs`, failing **closed** if that can't be confirmed.
Must run AFTER `smolvm machine create` (the row has to
exist) and BEFORE `smolvm machine start` (smolvm reads the
row on start; in-flight VMs don't pick up changes). Once
smolvm honors the CLI flag upstream this whole function is
redundant — flag-respecting create + remove this call from
launch.
Runs on both macOS and Linux. It exists because smolvm 0.8.0
silently drops `--allow-cidr` when combined with `--from`, so
the allowlist has to be written into smolvm's persistent state
DB before `machine start`. Rather than assume the flag was
dropped, we read the persisted row and only patch when it
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
smolmachines code path isn't exercised in v1."""
if not _is_macos():
return
Must run AFTER `smolvm machine create` (the row has to exist)
and BEFORE `smolvm machine start` (smolvm reads the row on
start; in-flight VMs don't pick up changes).
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():
die(
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
f"smolvm 0.8.0 expected? `smolvm --version` to check."
f"smolvm state DB not found at {_SMOLVM_DB_PATH}; cannot "
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))
try:
cur = con.cursor()
row = cur.execute(
cfg = _read_machine_cfg(con, machine_name)
if cfg.get("allowed_cidrs") != want:
cfg["allowed_cidrs"] = want
# 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.
con.execute(
"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)."
)
finally:
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:
@@ -162,18 +214,7 @@ def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
f"smolvm DB has no row for machine {machine_name!r}"
f"machine_create must run before force_allowlist."
)
cfg = json.loads(row[0])
cfg["allowed_cidrs"] = list(allowed_cidrs)
# Write as BLOB (the column type smolvm uses) — passing a
# plain str makes sqlite store it as Text and smolvm then
# fails to read it.
cur.execute(
"UPDATE vms SET data = ? WHERE name = ?",
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
)
con.commit()
finally:
con.close()
return json.loads(row[0])
def allocate(_slug: str) -> str:
@@ -184,16 +225,17 @@ def allocate(_slug: str) -> str:
used (no on-disk reservation, allocation is purely
docker-state-driven).
On non-macOS the whole `127.0.0.0/8` is loopback by default;
`127.0.0.1` is fine to share and we skip the alias dance.
This still returns a deterministic address so launch.py's
callers don't have to branch on platform.
Runs on both platforms: the allocation logic (docker-state
inspection + the file lock) is platform-independent. macOS
needs `ensure_pool` to have aliased the addresses on `lo0`
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
simultaneous launches don't read the same docker state and
claim the same alias."""
if not _is_macos():
return "127.0.0.1"
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
+10 -15
View File
@@ -116,10 +116,9 @@ def machine_create(
allow_cidrs: Sequence[str] = (),
env: Mapping[str, str] | None = None,
) -> None:
"""`smolvm machine create NAME [--image IMG | --from PATH]
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is positional
(the CLI's exception to the `--name` pattern other
subcommands use).
"""`smolvm machine create --name NAME [--image IMG | --from PATH]
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is passed as
`--name` (smolvm 1.4.7+; earlier versions took it positionally).
`image` (registry ref like `alpine:latest`) and `from_path`
(a `.smolmachine` artifact) are mutually exclusive — one or
@@ -133,12 +132,10 @@ def machine_create(
result without the Smolfile complication.
`--net` is sent explicitly when `allow_cidrs` is non-empty.
smolvm 0.8.0's docs say `--allow-cidr` implies `--net`, but
empirically the implication only fires when no `--from` is
set — `--from PATH --allow-cidr X/32` silently produces a
machine with `network: false` and no routes in the guest, so
the agent can't reach the bundle's pinned IP."""
args: list[str] = ["machine", "create"]
`--allow-cidr` implies `--net` per the CLI help, but sending
`--net` explicitly is harmless and ensures the guest has
network access even if that implication changes across versions."""
args: list[str] = ["machine", "create", "--name", name]
if image is not None:
args += ["--image", image]
if from_path is not None:
@@ -150,7 +147,6 @@ def machine_create(
if env:
for k, v in env.items():
args += ["-e", f"{k}={v}"]
args.append(name)
_smolvm(*args)
@@ -182,10 +178,9 @@ def machine_stop(name: str) -> None:
def machine_delete(name: str) -> None:
"""`smolvm machine delete -f NAME`. NAME is positional. `-f`
skips the interactive confirmation — required for
non-interactive teardown."""
_smolvm("machine", "delete", "-f", name)
"""`smolvm machine delete --name NAME -f`. `-f` skips the
interactive confirmation — required for non-interactive teardown."""
_smolvm("machine", "delete", "--name", name, "-f")
def machine_exec(
+39 -7
View File
@@ -5,19 +5,28 @@ unit-tested without importing the docker subprocess paths."""
from __future__ import annotations
import hashlib
import os
import platform
import shutil
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:
"""Ensure `smolvm` is on PATH before the launch flow runs.
Called from `_resolve_plan`; gives the operator a clear
install pointer rather than a cryptic FileNotFoundError
later. `gvproxy` is no longer required — see the PRD's design
pivot section."""
if shutil.which("smolvm") is not None:
return
"""Ensure the host can run the smolmachines backend before the
launch flow starts. Called from `_resolve_plan`; surfaces a
clear, actionable error instead of a cryptic `smolvm` failure
deep in launch.
Checks `smolvm` is on PATH (both platforms) and, on Linux,
that `/dev/kvm` exists and is accessible. `gvproxy` is no
longer required — see the PRD's design pivot section."""
if shutil.which("smolvm") is None:
die(
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"PATH. Install with: "
@@ -25,6 +34,29 @@ def smolmachines_preflight() -> None:
"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]:
+2 -3
View File
@@ -284,9 +284,8 @@ def git_gate_state_dir(identity: str) -> Path:
def supervise_state_dir(identity: str) -> Path:
"""State subdir reserved for supervise sidecar bind-mount sources.
The queue dir is intentionally NOT under here — it lives at
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
survives state-dir cleanup."""
Runtime queue/audit rows live in the host-level bot-bottle SQLite
database, so they survive state-dir cleanup."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
+18 -2
View File
@@ -7,8 +7,10 @@ from __future__ import annotations
import sys
from ..errors import MissingEnvVarError
from ..log import Die, die, error
from ..manifest import ManifestError
from ..store_manager import StoreManager
from ._common import PROG
from . import list as _list_mod
from .cleanup import cmd_cleanup
@@ -74,11 +76,25 @@ def main(argv: list[str] | None = None) -> int:
if handler is None:
usage()
die(f"unknown command: {command}")
mgr = StoreManager.instance()
if not mgr.is_migrated():
sys.stderr.write("bot-bottle: database schema is out of date\n")
sys.stderr.write("Migrate now? [y/N] ")
sys.stderr.flush()
try:
answer = sys.stdin.readline().strip().lower()
except EOFError:
answer = ""
if answer != "y":
error("migration required — re-run and confirm to migrate")
return 1
mgr.migrate()
try:
return handler(rest) or 0
except MissingEnvVarError as e:
error(str(e))
return 1
except ManifestError as e:
# Manifest/config problems surface as a catchable exception;
# print the reason and exit non-zero (same UX die() used to give).
error(str(e))
return 1
except Die as e:
-24
View File
@@ -27,34 +27,12 @@ from .start import _launch_bottle
def cmd_resume(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--headless",
action="store_true",
help=(
"non-interactive rehydrate: deliver --prompt to the agent and "
"skip the y/N preflight. For orchestrators / the freeze-rehydrate "
"loop."
),
)
parser.add_argument(
"--prompt",
default=None,
help="follow-up prompt delivered to the agent (required with --headless)",
)
parser.add_argument(
"identity",
help="bottle identity from a prior `start` (see its session-end output)",
)
args = parser.parse_args(argv)
if args.prompt and not args.headless:
die("--prompt is only valid with --headless")
if args.headless and not args.prompt:
die(
"--headless requires --prompt: "
"./cli.py resume <identity> --headless --prompt 'Address the review'"
)
metadata = read_metadata(args.identity)
if metadata is None:
die(
@@ -78,6 +56,4 @@ def cmd_resume(argv: list[str]) -> int:
spec,
dry_run=args.dry_run,
backend_name=backend_name,
assume_yes=args.headless,
headless_prompt_text=args.prompt or "",
)
+11 -3
View File
@@ -108,6 +108,13 @@ def cmd_start(argv: list[str]) -> int:
agent_name: str | None = args.name
if agent_name is None:
if not manifest.all_agent_names:
print(
"bot-bottle: no agents defined. "
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
file=sys.stderr,
)
return 1
agent_name = tui.filter_select(
manifest.all_agent_names,
title="Select agent",
@@ -235,7 +242,7 @@ def prepare_with_preflight(
spec: BottleSpec,
*,
stage_dir: Path,
render_preflight: Callable[[DockerBottlePlan], None],
render_preflight: Callable[[DockerBottlePlan, str], None],
prompt_yes: Callable[[], bool],
dry_run: bool = False,
backend_name: str | None = None,
@@ -256,7 +263,7 @@ def prepare_with_preflight(
plan = backend.prepare(spec, stage_dir=stage_dir)
identity = _identity_from_plan(plan)
render_preflight(plan)
render_preflight(plan, backend.name)
if dry_run:
info("dry-run requested; not starting container.")
@@ -383,8 +390,9 @@ def _text_prompt_yes() -> bool:
def _text_render_preflight():
def _render(plan: DockerBottlePlan) -> None:
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
print(file=sys.stderr)
print(f"backend: {backend_name}", file=sys.stderr)
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
return _render
+9 -16
View File
@@ -45,7 +45,7 @@ from ..supervise import (
TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW,
list_pending_proposals,
list_all_pending_proposals,
render_diff,
write_audit_entry,
write_response,
@@ -63,10 +63,9 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
@dataclass(frozen=True)
class QueuedProposal:
"""A pending proposal plus the queue dir it was found in."""
"""A pending proposal from the supervise queue."""
proposal: Proposal
queue_dir: Path
# Errors any remediation engine may raise. Caught by the TUI key
@@ -86,16 +85,11 @@ def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
def discover_pending() -> list[QueuedProposal]:
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
queue_root = _supervise.bot_bottle_root() / "queue"
if not queue_root.is_dir():
return []
out: list[QueuedProposal] = []
for slug_dir in sorted(queue_root.iterdir()):
if not slug_dir.is_dir():
continue
for proposal in list_pending_proposals(slug_dir):
out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir))
"""Collect pending proposals across bottles."""
out = [
QueuedProposal(proposal=proposal)
for proposal in list_all_pending_proposals()
]
out.sort(key=lambda q: q.proposal.arrival_timestamp)
return out
@@ -118,7 +112,6 @@ def _detail_lines(
(f"tool: {p.tool}", 0),
(f"id: {p.id}", 0),
(f"arrived: {p.arrival_timestamp}", 0),
(f"queue: {qp.queue_dir}", 0),
("", 0),
("justification:", 0),
]
@@ -165,7 +158,7 @@ def approve(
notes=notes,
final_file=final_file,
)
write_response(qp.queue_dir, response)
write_response(qp.proposal.bottle_slug, response)
_write_audit(
qp, action=status, notes=notes,
diff_before=diff_before, diff_after=diff_after,
@@ -179,7 +172,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
notes=reason,
final_file=None,
)
write_response(qp.queue_dir, response)
write_response(qp.proposal.bottle_slug, response)
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
+1 -1
View File
@@ -21,7 +21,7 @@ FROM node:22-slim
# to it) works against egress's bumped TLS without the agent needing
# local DNS.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep \
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep iproute2 dnsutils \
&& rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by claude-code itself
+10 -3
View File
@@ -34,6 +34,12 @@ if TYPE_CHECKING:
_SUPERVISE_MCP_NAME = "supervise"
_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex"
_CODEX_CLI_PATH = (
"/home/node/.local/bin:"
"/home/node/.codex/packages/standalone/current/bin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
)
def _skills_dir(guest_home: str) -> str:
@@ -50,7 +56,7 @@ def _prompt_path(guest_home: str) -> str:
_RUNTIME = AgentProviderRuntime(
template="codex",
command="codex",
command=_CODEX_CLI,
image="bot-bottle-codex:latest",
prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
@@ -145,7 +151,8 @@ class CodexAgentProvider(AgentProvider):
"env",
f"HOME={guest_home}",
f"CODEX_HOME={auth_dir}",
"codex", "login", "status",
f"PATH={_CODEX_CLI_PATH}",
_CODEX_CLI, "login", "status",
), (
"codex host credentials: dummy auth was copied into the "
"guest, but Codex did not accept it"
@@ -267,7 +274,7 @@ class CodexAgentProvider(AgentProvider):
return
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
r = bottle.exec(
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url "
f"{shlex.quote(supervise_url)}",
user="node",
)
-165
View File
@@ -1,165 +0,0 @@
"""Forge abstraction (PRD forge-native-integration, chunk 3).
The `Forge` abstract class is the provider-agnostic surface a forge
sidecar dispatches to: read issues/comments, post comments, edit
descriptions, and the membership / PR lookups the orchestrator needs.
Each forge (Gitea first) implements it; the sidecar protocol and the
agent prompt stay forge-agnostic.
`signal_done` is deliberately *not* a `Forge` method — completion is a
sidecar concept relayed to the orchestrator over a queue dir, not a
forge API operation.
`ScopedForge` enforces the PRD's **read-anywhere / write-scoped** model:
reads pass through to any issue/PR for context; writes are rejected
unless the target is the assigned issue or one of its PRs. This bounds
the blast radius of a prompt-injected agent below repo-wide API-key
permissions.
"""
from __future__ import annotations
import abc
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
class Issue:
"""A forge issue (not a PR — see `PullRequest`)."""
number: int
title: str
body: str
state: str # "open" | "closed"
@dataclass(frozen=True)
class PullRequest:
"""A forge pull request. Kept distinct from `Issue` even though some
forges model PRs as issues on the wire: the domain objects carry
different data (a PR has merge state) and are read through different
methods (`read_pr` vs `read_issue`)."""
number: int
title: str
body: str
state: str # "open" | "closed"
merged: bool
@dataclass(frozen=True)
class Comment:
id: int
user: str # login of the comment author
body: str
class ForgeScopeError(PermissionError):
"""Raised by `ScopedForge` when a write targets an issue/PR outside
the assigned scope."""
class Forge(abc.ABC):
"""Provider-agnostic forge operations. Implementations wrap a
per-provider HTTP client and translate to `Issue` / `Comment`."""
@abc.abstractmethod
def read_issue(self, number: int) -> Issue:
"""Read an issue body (read-anywhere)."""
@abc.abstractmethod
def read_pr(self, number: int) -> PullRequest:
"""Read a pull request, including its merge state (read-anywhere)."""
@abc.abstractmethod
def read_comments(self, number: int) -> list[Comment]:
"""Read a thread's comments (read-anywhere)."""
@abc.abstractmethod
def post_comment(self, number: int, body: str) -> None:
"""Post a comment to an issue or PR (write-scoped)."""
@abc.abstractmethod
def update_description(self, number: int, body: str) -> None:
"""Replace an issue or PR body (write-scoped)."""
@abc.abstractmethod
def is_org_member(self, org: str, username: str) -> bool:
"""Whether `username` is a member of `org`."""
@abc.abstractmethod
def get_pr_for_issue(self, number: int) -> int | None:
"""The PR number linked to an issue, or None when there is none."""
@abc.abstractmethod
def is_pr_open(self, number: int) -> bool:
"""Whether the given PR is still open."""
class ScopedForge(Forge):
"""Read-anywhere / write-scoped wrapper around a concrete `Forge`.
`post_comment` and `update_description` are rejected with
`ForgeScopeError` unless the target number is the assigned issue or
one of the assigned PRs. Every other method delegates unchanged, so
reads, membership checks, and PR lookups work against any number for
context.
The writable set is fixed at construction. The sidecar reconstructs
a `ScopedForge` when a PR is discovered (`get_pr_for_issue`) so the
new PR becomes writable; this class does not mutate its own scope.
"""
def __init__(
self,
inner: Forge,
*,
assigned_issue: int,
assigned_prs: Iterable[int] = (),
) -> None:
self._inner = inner
self._assigned_issue = assigned_issue
self._writable = {assigned_issue, *assigned_prs}
@property
def writable(self) -> frozenset[int]:
return frozenset(self._writable)
def _check_write(self, number: int) -> None:
if number not in self._writable:
allowed = ", ".join(str(n) for n in sorted(self._writable))
raise ForgeScopeError(
f"write to #{number} denied: out of assigned scope "
f"(writable: {allowed})"
)
# --- read-anywhere: pass through --------------------------------------
def read_issue(self, number: int) -> Issue:
return self._inner.read_issue(number)
def read_pr(self, number: int) -> PullRequest:
return self._inner.read_pr(number)
def read_comments(self, number: int) -> list[Comment]:
return self._inner.read_comments(number)
def is_org_member(self, org: str, username: str) -> bool:
return self._inner.is_org_member(org, username)
def get_pr_for_issue(self, number: int) -> int | None:
return self._inner.get_pr_for_issue(number)
def is_pr_open(self, number: int) -> bool:
return self._inner.is_pr_open(number)
# --- write-scoped: check then delegate --------------------------------
def post_comment(self, number: int, body: str) -> None:
self._check_write(number)
self._inner.post_comment(number, body)
def update_description(self, number: int, body: str) -> None:
self._check_write(number)
self._inner.update_description(number, body)
-174
View File
@@ -1,174 +0,0 @@
"""Gitea HTTP client + `GiteaForge` (PRD forge-native-integration, chunk 3).
`GiteaClient` is the thin stdlib-only HTTP transport (mirrors
`deploy_key_provisioner.py`: `urllib.request`, bounded timeouts,
structured error bodies). `GiteaForge` adapts it to the provider-agnostic
`Forge` surface.
Unlike the option-2 design, the token is held here (the sidecar process
owns it) and passed to the client directly — there is no agent-side
cred-proxy route, because the agent never makes forge calls. The HTTP
client is the one piece shared with `GiteaDeployKeyProvisioner`; the two
are deliberately *not* unified behind a common abstract base (see the
deferral note in the PRD).
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import Any
from ..forge.base import Comment, Forge, Issue, PullRequest
# Bound every Gitea call: a hung instance must not stall the sidecar.
_API_TIMEOUT_SECS = 30
class GiteaClient:
"""Thin authenticated HTTP client for one repo's Gitea API.
`api_url` is the API base *including* `/api/v1` (matching the
`FORGE_GITEA_API` env var), e.g. `https://gitea.example.com/api/v1`.
"""
def __init__(self, *, api_url: str, owner: str, repo: str, token: str) -> None:
self._api_url = api_url.rstrip("/")
self._owner = owner
self._repo = repo
self._token = token
# --- low-level request -------------------------------------------------
def _request(
self, method: str, path: str, *, body: dict[str, Any] | None = None
) -> tuple[int, Any]:
"""Issue an authenticated request. Returns `(status, parsed_json)`;
parsed_json is None when the response has no body. Raises
`RuntimeError` on any non-2xx except where callers special-case
the HTTPError themselves (membership 404)."""
url = f"{self._api_url}{path}"
data = json.dumps(body).encode() if body is not None else None
headers = {"Authorization": f"token {self._token}"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS) as resp:
raw = resp.read()
parsed = json.loads(raw) if raw else None
return resp.status, parsed
def _repo_path(self, suffix: str) -> str:
return f"/repos/{self._owner}/{self._repo}{suffix}"
# --- operations --------------------------------------------------------
def is_org_member(self, org: str, username: str) -> bool:
"""GET /orgs/{org}/members/{username}: 2xx → member, 404 → not.
Other errors propagate so a misconfigured token fails loudly."""
url = f"{self._api_url}/orgs/{org}/members/{username}"
req = urllib.request.Request(
url, headers={"Authorization": f"token {self._token}"}, method="GET"
)
try:
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS):
return True
except urllib.error.HTTPError as exc:
if exc.code == 404:
return False
raise RuntimeError(
f"org membership check failed for {org}/{username}: "
f"HTTP {exc.code}{_read_error_body(exc)}"
) from exc
def get_issue(self, number: int) -> dict[str, Any]:
_status, body = self._request("GET", self._repo_path(f"/issues/{number}"))
return body or {}
def get_comments(self, number: int) -> list[dict[str, Any]]:
_status, body = self._request(
"GET", self._repo_path(f"/issues/{number}/comments")
)
return body or []
def post_comment(self, number: int, body: str) -> None:
self._request(
"POST",
self._repo_path(f"/issues/{number}/comments"),
body={"body": body},
)
def patch_issue_body(self, number: int, body: str) -> None:
self._request(
"PATCH", self._repo_path(f"/issues/{number}"), body={"body": body}
)
def get_pull(self, number: int) -> dict[str, Any]:
_status, body = self._request("GET", self._repo_path(f"/pulls/{number}"))
return body or {}
class GiteaForge(Forge):
"""`Forge` over a `GiteaClient`."""
def __init__(self, client: GiteaClient) -> None:
self._client = client
def read_issue(self, number: int) -> Issue:
raw = self._client.get_issue(number)
return Issue(
number=int(raw.get("number", number)),
title=str(raw.get("title", "")),
body=str(raw.get("body", "") or ""),
state=str(raw.get("state", "")),
)
def read_pr(self, number: int) -> PullRequest:
raw = self._client.get_pull(number)
return PullRequest(
number=int(raw.get("number", number)),
title=str(raw.get("title", "")),
body=str(raw.get("body", "") or ""),
state=str(raw.get("state", "")),
merged=bool(raw.get("merged", False)),
)
def read_comments(self, number: int) -> list[Comment]:
return [
Comment(
id=int(c.get("id", 0)),
user=str((c.get("user") or {}).get("login", "")),
body=str(c.get("body", "") or ""),
)
for c in self._client.get_comments(number)
]
def post_comment(self, number: int, body: str) -> None:
self._client.post_comment(number, body)
def update_description(self, number: int, body: str) -> None:
self._client.patch_issue_body(number, body)
def is_org_member(self, org: str, username: str) -> bool:
return self._client.is_org_member(org, username)
def get_pr_for_issue(self, number: int) -> int | None:
"""Gitea models a PR as an issue with the same number, exposing a
`pull_request` object on the issue. When the queried number is
itself a PR, return it; otherwise None. (The orchestrator tracks
the issue→PR mapping in forge state for the cross-number case.)"""
raw = self._client.get_issue(number)
if raw.get("pull_request"):
return int(raw.get("number", number))
return None
def is_pr_open(self, number: int) -> bool:
return self.read_pr(number).state == "open"
def _read_error_body(exc: urllib.error.HTTPError) -> str:
try:
return exc.read().decode("utf-8", errors="replace")
except Exception: # pylint: disable=broad-exception-caught
return ""
-171
View File
@@ -1,171 +0,0 @@
"""Forge state persistence (PRD forge-native-integration, chunk 2).
The orchestrator tracks one record per forge-targeted issue so it can
map an incoming webhook back to the bottle handling it, drive the
freeze / rehydrate loop, and run the watchdog.
State is stored in a local SQLite database in `~/.bot-bottle/`. Access
goes through the thin `ForgeStateStore` CRUD interface so the backing
store (location or engine) can be swapped without touching callers;
`SqliteForgeStateStore` is the first implementation.
"""
from __future__ import annotations
import abc
import json
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from ...supervise import bot_bottle_root
_DB_FILENAME = "bot-bottle.db"
# Lifecycle: a bottle is launched (running), frozen on the done signal,
# and destroyed when the PR closes.
STATUS_RUNNING = "running"
STATUS_FROZEN = "frozen"
STATUS_DESTROYED = "destroyed"
@dataclass
class ForgeState:
"""One forge-targeted issue's bottle lifecycle record."""
owner: str
repo: str
issue_number: int
slug: str
agent_name: str
bottle_names: list[str] = field(default_factory=list)
backend_name: str = ""
agent_git_user: str = ""
pr_number: int | None = None
status: str = STATUS_RUNNING
last_checkin_at: str = ""
class ForgeStateStore(abc.ABC):
"""Thin CRUD surface over forge state. Implementations back it with a
concrete store; callers depend only on this interface so the storage
location/engine is swappable."""
@abc.abstractmethod
def upsert(self, state: ForgeState) -> None:
"""Insert or replace the record keyed by (owner, repo, issue)."""
@abc.abstractmethod
def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None:
"""Fetch one record, or None when absent."""
@abc.abstractmethod
def delete(self, owner: str, repo: str, issue_number: int) -> None:
"""Remove a record. Missing is success (idempotent)."""
@abc.abstractmethod
def all(self) -> list[ForgeState]:
"""Every record, for the status table and the watchdog sweep."""
def default_db_path() -> Path:
return bot_bottle_root() / _DB_FILENAME
class SqliteForgeStateStore(ForgeStateStore):
"""SQLite-backed `ForgeStateStore`. The database lives at
`~/.bot-bottle/bot-bottle.db` by default; pass `db_path` to point at
a different location (tests, alternate homes)."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path = db_path or default_db_path()
self._db_path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS forge_state (
owner TEXT NOT NULL,
repo TEXT NOT NULL,
issue_number INTEGER NOT NULL,
slug TEXT NOT NULL,
agent_name TEXT NOT NULL,
bottle_names TEXT NOT NULL,
backend_name TEXT NOT NULL,
agent_git_user TEXT NOT NULL,
pr_number INTEGER,
status TEXT NOT NULL,
last_checkin_at TEXT NOT NULL,
PRIMARY KEY (owner, repo, issue_number)
)
"""
)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
return conn
def upsert(self, state: ForgeState) -> None:
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO forge_state (
owner, repo, issue_number, slug, agent_name,
bottle_names, backend_name, agent_git_user,
pr_number, status, last_checkin_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
state.owner,
state.repo,
state.issue_number,
state.slug,
state.agent_name,
json.dumps(state.bottle_names),
state.backend_name,
state.agent_git_user,
state.pr_number,
state.status,
state.last_checkin_at,
),
)
def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM forge_state "
"WHERE owner = ? AND repo = ? AND issue_number = ?",
(owner, repo, issue_number),
).fetchone()
return _row_to_state(row) if row is not None else None
def delete(self, owner: str, repo: str, issue_number: int) -> None:
with self._connect() as conn:
conn.execute(
"DELETE FROM forge_state "
"WHERE owner = ? AND repo = ? AND issue_number = ?",
(owner, repo, issue_number),
)
def all(self) -> list[ForgeState]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM forge_state ORDER BY owner, repo, issue_number"
).fetchall()
return [_row_to_state(row) for row in rows]
def _row_to_state(row: sqlite3.Row) -> ForgeState:
return ForgeState(
owner=row["owner"],
repo=row["repo"],
issue_number=row["issue_number"],
slug=row["slug"],
agent_name=row["agent_name"],
bottle_names=json.loads(row["bottle_names"]),
backend_name=row["backend_name"],
agent_git_user=row["agent_git_user"],
pr_number=row["pr_number"],
status=row["status"],
last_checkin_at=row["last_checkin_at"],
)
+59
View File
@@ -0,0 +1,59 @@
"""Shared SQLite-backed store base class for bot-bottle (PRD 0013)."""
from __future__ import annotations
import sqlite3
from pathlib import Path
try:
from .migrations import TableMigrations
except ImportError:
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
class DbVersionError(Exception):
"""Raised when the on-disk schema is behind the current migration list."""
class DbStore:
"""Base for SQLite-backed stores. Subclasses resolve db_path then call super().__init__."""
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
self.db_path = db_path
self._migrations = migrations
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def is_migrated(self) -> bool:
"""Return True if the DB is fully up-to-date, False if migration is needed."""
if not self.db_path.exists():
return False
try:
with self._connect() as conn:
row = conn.execute(
"SELECT version FROM schema_versions WHERE module = ?",
(self._migrations.schema_key,),
).fetchone()
except sqlite3.OperationalError:
return False
version = row[0] if row else 0
return version == len(self._migrations.migrations)
def migrate(self) -> None:
"""Apply any pending migrations and set permissions on the DB file."""
with self._connect() as conn:
self._migrations.apply(conn)
self._chmod()
def _chmod(self) -> None:
try:
self.db_path.chmod(0o600)
except OSError:
pass
__all__ = ["DbStore", "DbVersionError"]
+7 -4
View File
@@ -23,6 +23,7 @@ from .egress_addon_core import (
PathMatch as CorePathMatch,
Route,
)
from .errors import MissingEnvVarError
from .log import die
if TYPE_CHECKING:
@@ -354,16 +355,18 @@ def egress_resolve_token_values(
for token_env, token_ref in token_env_map.items():
value = host_env.get(token_ref)
if value is None:
die(
raise MissingEnvVarError(
token_ref,
f"egress: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding auth block "
f"from bottle.egress.routes."
f"from bottle.egress.routes.",
)
if not value:
die(
raise MissingEnvVarError(
token_ref,
f"egress: host env var '{token_ref}' is empty. The "
f"egress will not inject an empty token; set it to "
f"the real value or remove the route's auth block."
f"the real value or remove the route's auth block.",
)
out[token_env] = value
return out
+6 -9
View File
@@ -79,14 +79,13 @@ class EgressAddon:
# only — a restart re-prompts. Mutated only from the asyncio loop that
# runs the addon hooks, so no lock is needed.
self.safe_tokens: set[str] = set()
self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip()
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True)
self._install_sighup()
def _supervise_available(self) -> bool:
return bool(self._supervise_queue_dir and self._supervise_slug)
return bool(self._supervise_slug)
def _reload(self, *, initial: bool = False) -> None:
try:
@@ -393,9 +392,8 @@ class EgressAddon:
justification=_TOKEN_ALLOW_JUSTIFICATION,
current_file_hash=_sv.sha256_hex(payload),
)
queue_dir = Path(self._supervise_queue_dir)
try:
_sv.write_proposal(queue_dir, proposal)
_sv.write_proposal(proposal)
except OSError as e:
sys.stderr.write(
f"egress: could not queue token-allow proposal: {e}; "
@@ -411,8 +409,8 @@ class EgressAddon:
**self._req_ctx(flow),
}) + "\n")
response = await self._await_token_response(queue_dir, proposal.id)
_sv.archive_proposal(queue_dir, proposal.id)
response = await self._await_token_response(proposal.id)
_sv.archive_proposal(self._supervise_slug, proposal.id)
if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
@@ -439,16 +437,15 @@ class EgressAddon:
async def _await_token_response(
self,
queue_dir: Path,
proposal_id: str,
) -> "_sv.Response | None":
"""Poll the queue dir for the operator's response without blocking the
"""Poll the DB for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout."""
loop = asyncio.get_running_loop()
deadline = loop.time() + self._token_allow_timeout
while True:
try:
return _sv.read_response(queue_dir, proposal_id)
return _sv.read_response(self._supervise_slug, proposal_id)
except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed.
+4 -2
View File
@@ -35,6 +35,7 @@ import re
import sys
from dataclasses import dataclass, field
from .errors import MissingEnvVarError
from .log import die
from .manifest import Manifest
@@ -136,9 +137,10 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv:
host_var = env_entry_interpolated_from(raw)
host_value = os.environ.get(host_var, "")
if not host_value:
die(
raise MissingEnvVarError(
host_var,
f"env entry {name} is interpolated from ${host_var}, "
f"but ${host_var} is unset or empty in the host environment."
f"but ${host_var} is unset or empty in the host environment.",
)
forwarded[name] = host_value
else: # literal
+18
View File
@@ -0,0 +1,18 @@
"""bot-bottle application-level exception hierarchy.
Exceptions here are caught by the CLI dispatcher (``cli/__init__.py``)
and rendered as ``bot-bottle: error: `` lines same UX as ``die()``,
but without coupling library code to stderr output."""
from __future__ import annotations
class MissingEnvVarError(Exception):
"""Raised when a required host environment variable is unset or empty.
``var_name`` is the exact name of the missing variable so callers
can display it without re-parsing the message string."""
def __init__(self, var_name: str, message: str) -> None:
self.var_name = var_name
super().__init__(message)
+7 -11
View File
@@ -30,7 +30,6 @@ backend-specific and lives on concrete subclasses (see
from __future__ import annotations
import dataclasses
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
@@ -53,6 +52,7 @@ from .git_gate_render import (
_gitconfig_validate_value,
)
from .git_gate_provision import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
_provision_dynamic_key,
_resolve_identity_file,
@@ -82,6 +82,7 @@ class GitGatePlan:
class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the sidecar's
@@ -93,20 +94,14 @@ class GitGate(ABC):
entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess.
For `gitea` key entries, also generates and registers
a fresh deploy key via the forge API and writes the private key
+ key ID to `stage_dir`.
For `gitea` key entries, the returned upstream intentionally
has an empty identity file. Backend launch fills that in after
the operator confirms the preflight.
Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` via `dataclasses.replace`
before passing the plan to `.start`."""
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
for i, entry in enumerate(bottle.git):
upstreams_list[i] = dataclasses.replace(
upstreams_list[i],
identity_file=_resolve_identity_file(entry, slug, stage_dir),
)
upstreams = tuple(upstreams_list)
upstreams = git_gate_upstreams_for_bottle(bottle)
entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
entrypoint.chmod(0o600)
@@ -162,6 +157,7 @@ __all__ = [
"git_gate_render_entrypoint",
"git_gate_render_hook",
"git_gate_render_access_hook",
"provision_git_gate_dynamic_keys",
"revoke_git_gate_provisioned_keys",
"_gitconfig_validate_value",
"_provision_dynamic_key",
+50 -4
View File
@@ -9,10 +9,17 @@ imported (`deploy_key_provisioner`) to keep its cost off the host path.
from __future__ import annotations
import os
import dataclasses
from pathlib import Path
from typing import TYPE_CHECKING
from .errors import MissingEnvVarError
from .log import info
from .manifest import ManifestBottle, ManifestGitEntry
from .git_gate_render import GitGateUpstream
if TYPE_CHECKING:
from .git_gate import GitGatePlan
def _provision_dynamic_key(
entry: ManifestGitEntry,
@@ -28,9 +35,10 @@ def _provision_dynamic_key(
pk = entry.Key
token = os.environ.get(pk.forge_token_env)
if token is None:
raise RuntimeError(
raise MissingEnvVarError(
pk.forge_token_env,
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
f" = {pk.forge_token_env!r}: env var is not set"
f" = {pk.forge_token_env!r}: env var is not set",
)
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url)
@@ -72,10 +80,11 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
key_id = id_file.read_text().strip()
token = os.environ.get(pk.forge_token_env)
if token is None:
raise RuntimeError(
raise MissingEnvVarError(
pk.forge_token_env,
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
f" = {pk.forge_token_env!r}: env var is not set;"
f" cannot revoke deploy key {key_id}"
f" cannot revoke deploy key {key_id}",
)
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url)
@@ -95,8 +104,45 @@ def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path)
return entry.IdentityFile
def provision_git_gate_dynamic_keys(
bottle: ManifestBottle,
plan: "GitGatePlan",
stage_dir: Path,
) -> "GitGatePlan":
"""Provision dynamic git-gate keys and return an updated plan.
This runs during backend launch, after the operator confirms the
preflight. Plan preparation intentionally stays side-effect-light:
dry-runs and aborted launches must not create remote deploy keys.
"""
if not plan.upstreams:
return plan
upstreams_by_name: dict[str, GitGateUpstream] = {
upstream.name: upstream for upstream in plan.upstreams
}
updated: list[GitGateUpstream] = []
for entry in bottle.git:
upstream = upstreams_by_name.get(entry.Name)
if upstream is None:
continue
if entry.Key.provider == "gitea":
identity_file = _provision_dynamic_key(entry, plan.slug, stage_dir)
upstream = dataclasses.replace(upstream, identity_file=identity_file)
updated.append(upstream)
if len(updated) != len(plan.upstreams):
updated_names = {u.name for u in updated}
for upstream in plan.upstreams:
if upstream.name not in updated_names:
updated.append(upstream)
return dataclasses.replace(plan, upstreams=tuple(updated))
__all__ = [
"revoke_git_gate_provisioned_keys",
"provision_git_gate_dynamic_keys",
"_provision_dynamic_key",
"_resolve_identity_file",
]
+48 -44
View File
@@ -228,19 +228,22 @@ supervise_gitleaks_allow() {
fi
proposal_id=$(
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
import datetime
import hashlib
import json
import os
import sys
import uuid
from pathlib import Path
try:
import supervise as _sv
except ImportError:
from bot_bottle import supervise as _sv
report_path = Path(sys.argv[1])
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not queue_dir or not slug:
if not slug:
sys.exit(2)
try:
@@ -277,31 +280,19 @@ for i, finding in enumerate(raw, 1):
])
payload = "\n".join(lines).rstrip() + "\n"
proposal_id = str(uuid.uuid4())
proposal = {
"id": proposal_id,
"bottle_slug": slug,
"tool": "gitleaks-allow",
"proposed_file": payload,
"justification": (
proposal = _sv.Proposal.new(
bottle_slug=slug,
tool=_sv.TOOL_GITLEAKS_ALLOW,
proposed_file=payload,
justification=(
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
"approve only for dummy test fixtures or confirmed false positives"
),
"arrival_timestamp": datetime.datetime.now(
datetime.timezone.utc
).isoformat(),
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
}
queue = Path(queue_dir)
queue.mkdir(parents=True, exist_ok=True)
path = queue / f"{proposal_id}.proposal.json"
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(proposal, f, indent=2)
f.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
print(proposal_id)
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
now=datetime.datetime.now(datetime.timezone.utc),
)
_sv.write_proposal(proposal)
print(proposal.id)
PY
)
rc=$?
@@ -314,8 +305,7 @@ PY
return 1
fi
queue_dir=${SUPERVISE_QUEUE_DIR:-}
response_file="$queue_dir/${proposal_id}.response.json"
slug=${SUPERVISE_BOTTLE_SLUG:-}
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
case "$timeout" in
''|*[!0-9]*)
@@ -327,26 +317,41 @@ PY
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
waited=0
while [ "$waited" -lt "$timeout" ]; do
if [ -f "$response_file" ]; then
status=$(python3 - "$response_file" <<'PY'
import json
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY'
import sys
try:
with open(sys.argv[1], encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
sys.exit(1)
status = raw.get("status")
if not isinstance(status, str):
sys.exit(1)
print(status)
import supervise as _sv
except ImportError:
from bot_bottle import supervise as _sv
slug = sys.argv[1]
try:
response = _sv.read_response(slug, sys.argv[2])
except FileNotFoundError:
sys.exit(2)
print(response.status)
PY
) || status=""
)
rc=$?
if [ "$rc" -eq 2 ]; then
status=""
elif [ "$rc" -ne 0 ]; then
status="invalid"
fi
if [ -n "$status" ]; then
case "$status" in
approved|modified)
mkdir -p "$queue_dir/processed"
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' || true
import sys
try:
import supervise as _sv
except ImportError:
from bot_bottle import supervise as _sv
_sv.archive_proposal(sys.argv[1], sys.argv[2])
PY
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
return 0
;;
@@ -499,4 +504,3 @@ if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
fi
exit 0
"""
+7 -2
View File
@@ -16,11 +16,16 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
from .git_gate import GIT_GATE_TIMEOUT_SECS
DEFAULT_PORT = 9420
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
# imported: this module ships as a flat top-level sibling in the sidecar
# bundle image (see Dockerfile.sidecars), not as part of the bot_bottle
# package, so `bot_bottle.git_gate` and its dependency chain aren't
# available at runtime.
GIT_GATE_TIMEOUT_SECS = 15
# Bound memory use while still allowing ordinary git push packfiles.
MAX_BODY_BYTES = 100 * 1024 * 1024
+37
View File
@@ -0,0 +1,37 @@
"""SQLite migration runner for bot-bottle stores."""
from __future__ import annotations
import sqlite3
class TableMigrations:
"""Runs a sequential list of DDL migrations tracked by schema_key in schema_versions."""
def __init__(self, schema_key: str, migrations: list[str]) -> None:
self.schema_key = schema_key
self.migrations = migrations
def apply(self, conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_versions (
module TEXT PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 0
)
"""
)
row = conn.execute(
"SELECT version FROM schema_versions WHERE module = ?",
(self.schema_key,),
).fetchone()
version = row[0] if row else 0
for i, sql in enumerate(self.migrations[version:], start=version + 1):
conn.execute(sql)
conn.execute(
"INSERT OR REPLACE INTO schema_versions (module, version) VALUES (?, ?)",
(self.schema_key, i),
)
__all__ = ["TableMigrations"]
+215
View File
@@ -0,0 +1,215 @@
"""SQLite-backed queue store for supervise proposals and responses (PRD 0013)."""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
try:
from .supervise_types import Proposal, Response, host_db_path
from .db_store import DbStore
from .migrations import TableMigrations
except ImportError:
from supervise_types import Proposal, Response, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
class QueueStore(DbStore):
"""SQLite-backed persistent store for supervise proposals and responses."""
def __init__(self, queue_key: str, db_path: Path | None = None) -> None:
self.queue_key = queue_key
if db_path is not None:
resolved = db_path
else:
# In the sidecar container SUPERVISE_DB_PATH points at the
# bind-mounted host DB. On the host this env var is never set,
# so we always fall through to host_db_path().
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
resolved = Path(env_path) if env_path else host_db_path()
# One entry per schema version: migrations[0] brings a fresh DB to
# version 1, [1] to version 2, etc. Add new entries at the end; never
# edit existing ones.
migrations = TableMigrations("queue_store", [
# v1 — proposals table
"""
CREATE TABLE IF NOT EXISTS supervise_proposals (
queue_key TEXT NOT NULL,
id TEXT NOT NULL,
bottle_slug TEXT NOT NULL,
tool TEXT NOT NULL,
proposed_file TEXT NOT NULL,
justification TEXT NOT NULL,
arrival_timestamp TEXT NOT NULL,
current_file_hash TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (queue_key, id)
)
""",
# v2 — responses table
"""
CREATE TABLE IF NOT EXISTS supervise_responses (
queue_key TEXT NOT NULL,
proposal_id TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT NOT NULL,
final_file TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (queue_key, proposal_id)
)
""",
])
super().__init__(resolved, migrations)
def write_proposal(self, proposal: Proposal) -> Path:
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO supervise_proposals (
queue_key, id, bottle_slug, tool, proposed_file, justification,
arrival_timestamp, current_file_hash, archived
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
""",
(
self.queue_key,
proposal.id,
proposal.bottle_slug,
proposal.tool,
proposal.proposed_file,
proposal.justification,
proposal.arrival_timestamp,
proposal.current_file_hash,
),
)
self._chmod()
return self.db_path
def read_proposal(self, proposal_id: str) -> Proposal:
with self._connect() as conn:
row = conn.execute(
"""
SELECT * FROM supervise_proposals
WHERE queue_key = ? AND id = ? AND archived = 0
""",
(self.queue_key, proposal_id),
).fetchone()
if row is None:
raise FileNotFoundError(proposal_id)
return self._row_to_proposal(row)
def list_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file():
return []
with self._connect() as conn:
rows = conn.execute(
"""
SELECT p.* FROM supervise_proposals p
WHERE p.archived = 0
AND p.queue_key = ?
AND NOT EXISTS (
SELECT 1 FROM supervise_responses r
WHERE r.queue_key = p.queue_key
AND r.proposal_id = p.id
AND r.archived = 0
)
ORDER BY p.arrival_timestamp, p.id
""",
(self.queue_key,),
).fetchall()
return [self._row_to_proposal(row) for row in rows]
def list_all_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file():
return []
with self._connect() as conn:
rows = conn.execute(
"""
SELECT p.* FROM supervise_proposals p
WHERE p.archived = 0
AND NOT EXISTS (
SELECT 1 FROM supervise_responses r
WHERE r.queue_key = p.queue_key
AND r.proposal_id = p.id
AND r.archived = 0
)
ORDER BY p.arrival_timestamp, p.id
"""
).fetchall()
return [self._row_to_proposal(row) for row in rows]
def write_response(self, response: Response) -> Path:
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO supervise_responses (
queue_key, proposal_id, status, notes, final_file, archived
) VALUES (?, ?, ?, ?, ?, 0)
""",
(
self.queue_key,
response.proposal_id,
response.status,
response.notes,
response.final_file,
),
)
self._chmod()
return self.db_path
def read_response(self, proposal_id: str) -> Response:
with self._connect() as conn:
row = conn.execute(
"""
SELECT * FROM supervise_responses
WHERE queue_key = ? AND proposal_id = ? AND archived = 0
""",
(self.queue_key, proposal_id),
).fetchone()
if row is None:
raise FileNotFoundError(proposal_id)
return self._row_to_response(row)
def archive_proposal(self, proposal_id: str) -> None:
if not self.db_path.is_file():
return
with self._connect() as conn:
conn.execute(
"""
UPDATE supervise_proposals SET archived = 1
WHERE queue_key = ? AND id = ?
""",
(self.queue_key, proposal_id),
)
conn.execute(
"""
UPDATE supervise_responses SET archived = 1
WHERE queue_key = ? AND proposal_id = ?
""",
(self.queue_key, proposal_id),
)
@staticmethod
def _row_to_proposal(row: sqlite3.Row) -> Proposal:
return Proposal(
id=row["id"],
bottle_slug=row["bottle_slug"],
tool=row["tool"],
proposed_file=row["proposed_file"],
justification=row["justification"],
arrival_timestamp=row["arrival_timestamp"],
current_file_hash=row["current_file_hash"],
)
@staticmethod
def _row_to_response(row: sqlite3.Row) -> Response:
return Response(
proposal_id=row["proposal_id"],
status=row["status"],
notes=row["notes"],
final_file=row["final_file"],
)
__all__ = ["QueueStore"]
+60
View File
@@ -0,0 +1,60 @@
"""Singleton manager for all bot-bottle SQLite stores (PRD 0013)."""
from __future__ import annotations
from pathlib import Path
try:
from .audit_store import AuditStore
from .queue_store import QueueStore
except ImportError:
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
_instance: StoreManager | None = None
class StoreManager:
"""Owns db_path and delegates migrate/is_migrated across all stores.
Use instance() for normal access. Call reset(db_path) in tests to swap
the singleton to a temp path, then reset() with no args to restore the
default."""
def __init__(self, db_path: Path | None = None) -> None:
if db_path is None:
# Lazy import to avoid a circular dependency: supervise imports
# StoreManager at module level; StoreManager must not import
# supervise at module level in return.
try:
from .supervise import host_db_path
except ImportError:
from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
db_path = host_db_path()
self.db_path = db_path
@classmethod
def instance(cls) -> StoreManager:
global _instance
if _instance is None:
_instance = cls()
return _instance
@classmethod
def reset(cls, db_path: Path | None = None) -> None:
"""Replace the singleton. Pass db_path for test isolation; omit to restore default."""
global _instance
_instance = cls(db_path)
def is_migrated(self) -> bool:
return (
QueueStore("", self.db_path).is_migrated()
and AuditStore(self.db_path).is_migrated()
)
def migrate(self) -> None:
QueueStore("", self.db_path).migrate()
AuditStore(self.db_path).migrate()
__all__ = ["StoreManager"]
+112 -349
View File
@@ -9,15 +9,14 @@ calls when it needs an operator-reviewed egress change:
Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically,
writes it to the host's per-bottle queue dir, and holds the tool-call
writes it to the host SQLite queue table, and holds the tool-call
connection open. The operator's supervise TUI
(bot_bottle.cli.supervise) sees the proposal, accepts
approve / modify / reject, and writes a response file alongside the
proposal. The sidecar sees the response and returns `{status, notes}`
to the agent.
approve / modify / reject, and writes a response row. The sidecar sees
the response and returns `{status, notes}` to the agent.
This module defines the host-side library: dataclasses for the queue
file shapes, queue read/write helpers, the audit log writer, and the
record shapes, queue read/write helpers, the audit log writer, and the
diff renderer. The in-container sidecar lives in
bot_bottle/supervise_server.py; the supervise daemon's container
lifecycle is owned by the sidecar bundle (PRD 0024).
@@ -31,37 +30,56 @@ remediation engines that wire real config changes land in PRDs 0014,
from __future__ import annotations
import dataclasses
import difflib
import hashlib
import json
import os
import time
import uuid
from abc import ABC
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
try:
from .supervise_types import (
ACTION_OPERATOR_EDIT,
AuditEntry,
HOST_DB_FILENAME,
Proposal,
Response,
STATUSES,
STATUS_APPROVED,
STATUS_MODIFIED,
STATUS_REJECTED,
TOOLS,
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_EGRESS_TOKEN_ALLOW,
TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES,
bot_bottle_root,
)
except ImportError:
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
ACTION_OPERATOR_EDIT,
AuditEntry,
HOST_DB_FILENAME,
Proposal,
Response,
STATUSES,
STATUS_APPROVED,
STATUS_MODIFIED,
STATUS_REJECTED,
TOOLS,
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_EGRESS_TOKEN_ALLOW,
TOOL_GITLEAKS_ALLOW,
TOOL_LIST_EGRESS_ROUTES,
bot_bottle_root,
)
SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100
TOOL_EGRESS_BLOCK = "egress-block"
TOOL_EGRESS_ALLOW = "egress-allow"
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
# Written directly by the egress addon (not an agent-facing MCP tool) when an
# outbound DLP token block is routed to the operator for override (PRD 0062).
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = (
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW,
TOOL_LIST_EGRESS_ROUTES,
)
# The supervise sidecar uses these to query egress's
# introspection endpoint for the `list-egress-routes` MCP
# tool. The hostname + port match egress's docker network
@@ -77,236 +95,77 @@ COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_EGRESS_BLOCK: "egress",
}
STATUS_APPROVED = "approved"
STATUS_MODIFIED = "modified"
STATUS_REJECTED = "rejected"
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
# Operator-initiated audit entries (no tool call). PRD 0014's
# `routes edit <bottle>` verb writes entries with this action.
ACTION_OPERATOR_EDIT = "operator-edit"
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db"
DEFAULT_POLL_INTERVAL_SEC = 0.5
# --- Paths -----------------------------------------------------------------
def bot_bottle_root() -> Path:
return Path.home() / ".bot-bottle"
def queue_dir_for_slug(slug: str) -> Path:
return bot_bottle_root() / "queue" / slug
def audit_dir() -> Path:
return bot_bottle_root() / "audit"
def host_db_path() -> Path:
# Calls bot_bottle_root() through this module's globals so that patches
# on supervise.bot_bottle_root propagate to callers going through
# supervise.host_db_path().
#
# Kept in its own "db" subdirectory (see supervise_types.host_db_path
# for why) — must stay in sync with that copy.
return bot_bottle_root() / "db" / HOST_DB_FILENAME
def audit_log_path(component: str, slug: str) -> Path:
return audit_dir() / f"{component}-{slug}.log"
# --- Dataclasses -----------------------------------------------------------
@dataclass(frozen=True)
class Proposal:
"""One pending tool-call from the agent. The sidecar writes one
of these to the queue dir on a tool call; the operator's TUI
reads them; the sidecar polls for a matching Response."""
id: str
bottle_slug: str
tool: str
proposed_file: str
justification: str
arrival_timestamp: str
current_file_hash: str
@classmethod
def new(
cls,
*,
bottle_slug: str,
tool: str,
proposed_file: str,
justification: str,
current_file_hash: str,
now: datetime | None = None,
) -> "Proposal":
ts = (now or datetime.now(timezone.utc)).isoformat()
return cls(
id=str(uuid.uuid4()),
bottle_slug=bottle_slug,
tool=tool,
proposed_file=proposed_file,
justification=justification,
arrival_timestamp=ts,
current_file_hash=current_file_hash,
)
def to_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, raw: dict[str, object]) -> "Proposal":
tool = _require_str(raw, "tool")
if tool not in TOOLS:
raise ValueError(f"tool must be one of {TOOLS}; got {tool!r}")
return cls(
id=_require_str(raw, "id"),
bottle_slug=_require_str(raw, "bottle_slug"),
tool=tool,
proposed_file=_require_str(raw, "proposed_file"),
justification=_require_str(raw, "justification"),
arrival_timestamp=_require_str(raw, "arrival_timestamp"),
current_file_hash=_require_str(raw, "current_file_hash"),
)
@dataclass(frozen=True)
class Response:
"""The operator's decision on a proposal. The TUI writes one of
these to the queue dir; the sidecar reads it and returns the
`{status, notes}` pair to the agent's tool call.
`final_file` carries the file content the supervisor will
actually apply: for `approved`, equal to the proposal's
`proposed_file`; for `modified`, the operator's edited version
(the audit diff is current final_file, not current
proposed_file); for `rejected`, None."""
proposal_id: str
status: str
notes: str
final_file: str | None = None
def to_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, raw: dict[str, object]) -> "Response":
status = _require_str(raw, "status")
if status not in STATUSES:
raise ValueError(
f"response status must be one of {STATUSES}; got {status!r}"
)
final = raw.get("final_file")
if final is not None and not isinstance(final, str):
raise ValueError(
f"final_file must be a string or null; got {type(final).__name__}"
)
return cls(
proposal_id=_require_str(raw, "proposal_id"),
status=status,
notes=_require_str(raw, "notes"),
final_file=final,
)
@dataclass(frozen=True)
class AuditEntry:
"""One row of the per-bottle audit log. JSON-Lines, append-only."""
timestamp: str
bottle_slug: str
component: str
operator_action: str
operator_notes: str
justification: str
diff: str
def to_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
try:
from .queue_store import QueueStore
from .audit_store import AuditStore
from .store_manager import StoreManager
except ImportError:
# Sidecar bundle: files are flat-copied under /app, not a package.
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
# --- Queue I/O -------------------------------------------------------------
def _proposal_filename(proposal_id: str) -> str:
return f"{proposal_id}.proposal.json"
def _response_filename(proposal_id: str) -> str:
return f"{proposal_id}.response.json"
def _id_from_proposal_filename(path: Path) -> str | None:
name = path.name
if not name.endswith(".proposal.json"):
return None
return name[: -len(".proposal.json")]
def write_proposal(queue_dir: Path, proposal: Proposal) -> Path:
"""Persist `proposal` as JSON in the queue dir, mode 0o600.
def write_proposal(proposal: Proposal) -> Path:
"""Persist `proposal` in the queue database, mode 0o600.
Directory is created if missing."""
queue_dir.mkdir(parents=True, exist_ok=True)
path = queue_dir / _proposal_filename(proposal.id)
payload = json.dumps(proposal.to_dict(), indent=2) + "\n"
_atomic_write(path, payload, mode=0o600)
return path
return QueueStore(proposal.bottle_slug).write_proposal(proposal)
def read_proposal(queue_dir: Path, proposal_id: str) -> Proposal:
path = queue_dir / _proposal_filename(proposal_id)
with path.open() as f:
raw = json.load(f)
if not isinstance(raw, dict):
raise ValueError(f"{path}: top-level must be an object")
return Proposal.from_dict(raw)
def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal:
return QueueStore(bottle_slug).read_proposal(proposal_id)
def list_pending_proposals(queue_dir: Path) -> list[Proposal]:
"""All proposals in `queue_dir` that do not yet have a matching
response file. Sorted by `arrival_timestamp` so the operator
def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
"""All proposals for `bottle_slug` that do not yet have a matching
response. Sorted by `arrival_timestamp` so the operator
sees the queue FIFO."""
if not queue_dir.is_dir():
return []
out: list[Proposal] = []
for path in sorted(queue_dir.glob("*.proposal.json")):
proposal_id = _id_from_proposal_filename(path)
if proposal_id is None:
continue
if (queue_dir / _response_filename(proposal_id)).exists():
continue
try:
with path.open() as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
continue
if not isinstance(raw, dict):
continue
try:
out.append(Proposal.from_dict(raw))
except (KeyError, ValueError):
continue
out.sort(key=lambda p: p.arrival_timestamp)
return out
return QueueStore(bottle_slug).list_pending_proposals()
def write_response(queue_dir: Path, response: Response) -> Path:
queue_dir.mkdir(parents=True, exist_ok=True)
path = queue_dir / _response_filename(response.proposal_id)
payload = json.dumps(response.to_dict(), indent=2) + "\n"
_atomic_write(path, payload, mode=0o600)
return path
def list_all_pending_proposals() -> list[Proposal]:
"""All pending proposals across bottles, sorted FIFO."""
return QueueStore("").list_all_pending_proposals()
def read_response(queue_dir: Path, proposal_id: str) -> Response:
path = queue_dir / _response_filename(proposal_id)
with path.open() as f:
raw = json.load(f)
if not isinstance(raw, dict):
raise ValueError(f"{path}: top-level must be an object")
return Response.from_dict(raw)
def write_response(bottle_slug: str, response: Response) -> Path:
return QueueStore(bottle_slug).write_response(response)
def read_response(bottle_slug: str, proposal_id: str) -> Response:
return QueueStore(bottle_slug).read_response(proposal_id)
def wait_for_response(
queue_dir: Path,
bottle_slug: str,
proposal_id: str,
*,
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
@@ -317,90 +176,35 @@ def wait_for_response(
which the wait raises TimeoutError. None waits forever the
natural shape, since the operator's response time is unbounded.
Polls the filesystem so the implementation stays portable and
stdlib-only."""
path = queue_dir / _response_filename(proposal_id)
Polls SQLite so the implementation stays portable and stdlib-only."""
store = QueueStore(bottle_slug)
while True:
if path.exists():
try:
with path.open() as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
raw = None
if isinstance(raw, dict):
try:
return Response.from_dict(raw)
except (KeyError, ValueError):
return store.read_response(proposal_id)
except FileNotFoundError:
pass
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError(f"no response for proposal {proposal_id!r}")
time.sleep(poll_interval)
def archive_proposal(queue_dir: Path, proposal_id: str) -> None:
"""Move both proposal and response files to `<queue_dir>/processed/`.
Idempotent missing files are silently skipped."""
processed = queue_dir / "processed"
processed.mkdir(parents=True, exist_ok=True)
for name in (_proposal_filename(proposal_id), _response_filename(proposal_id)):
src = queue_dir / name
if src.exists():
src.rename(processed / name)
def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
"""Mark both proposal and response rows processed.
Idempotent missing rows are silently skipped."""
QueueStore(bottle_slug).archive_proposal(proposal_id)
# --- Audit log -------------------------------------------------------------
def write_audit_entry(entry: AuditEntry) -> Path:
"""Append `entry` as one JSON-Lines record to the per-bottle
audit log. Acquires an advisory exclusive lock so concurrent
writers don't interleave bytes."""
path = audit_log_path(entry.component, entry.bottle_slug)
path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(entry.to_dict(), sort_keys=False) + "\n"
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
try:
_try_flock(fd)
try:
os.write(fd, line.encode("utf-8"))
finally:
_try_funlock(fd)
finally:
os.close(fd)
return path
"""Append `entry` to the host supervise audit table."""
return AuditStore().write_audit_entry(entry)
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
"""Load all audit entries for the given component+slug. Empty
list if the log doesn't exist."""
path = audit_log_path(component, slug)
if not path.is_file():
return []
out: list[AuditEntry] = []
with path.open() as f:
for raw_line in f:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
raw = json.loads(raw_line)
except json.JSONDecodeError:
continue
if not isinstance(raw, dict):
continue
try:
out.append(AuditEntry(
timestamp=_require_str(raw, "timestamp"),
bottle_slug=_require_str(raw, "bottle_slug"),
component=_require_str(raw, "component"),
operator_action=_require_str(raw, "operator_action"),
operator_notes=_require_str(raw, "operator_notes"),
justification=_require_str(raw, "justification"),
diff=_require_str(raw, "diff"),
))
except ValueError:
continue
return out
"""Load all audit entries for the given component+slug."""
return AuditStore().read_audit_entries(component, slug)
# --- Diff rendering --------------------------------------------------------
@@ -433,89 +237,47 @@ def sha256_hex(content: str) -> str:
class SupervisePlan:
"""Output of Supervise.prepare; consumed by .start.
`queue_dir` is the host directory bind-mounted into the sidecar
at /run/supervise/queue. `internal_network` is empty at prepare
time; the backend's launch step fills it via dataclasses.replace
before calling .start."""
`db_path` is the host database bind-mounted into the sidecar at
/run/supervise/bot-bottle.db. `internal_network` is empty at
prepare time; the backend's launch step fills it via
dataclasses.replace before calling .start."""
slug: str
queue_dir: Path
db_path: Path
internal_network: str = ""
class Supervise(ABC):
"""Per-bottle supervise sidecar. Encapsulates the host-side
prepare (queue dir staging); the sidecar's start/stop lifecycle
is backend-specific."""
"""Per-bottle supervise sidecar. Encapsulates host-side database
staging; the sidecar's start/stop lifecycle is backend-specific."""
def prepare(
self,
slug: str,
stage_dir: Path,
) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host. Returns the
plan; `internal_network` must be set by the launch step before
.start runs."""
"""Stage the host database. Returns the plan; `internal_network`
must be set by the launch step before .start runs."""
del stage_dir
queue_dir = queue_dir_for_slug(slug)
queue_dir.mkdir(parents=True, exist_ok=True)
mgr = StoreManager.instance()
mgr.migrate()
return SupervisePlan(
slug=slug,
queue_dir=queue_dir,
db_path=mgr.db_path,
)
# --- Helpers ---------------------------------------------------------------
def _require_str(raw: dict[str, object], key: str) -> str:
value = raw.get(key)
if not isinstance(value, str):
raise ValueError(f"missing or non-string field {key!r}")
return value
def _atomic_write(path: Path, content: str, *, mode: int) -> None:
"""Atomic: write to a sibling tmp file, fsync, rename."""
tmp = path.with_suffix(path.suffix + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
try:
os.write(fd, content.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
os.replace(tmp, path)
try:
import fcntl as _fcntl
def _try_flock(fd: int) -> None: # type: ignore[reportRedeclaration]
try:
_fcntl.flock(fd, _fcntl.LOCK_EX)
except OSError:
pass
def _try_funlock(fd: int) -> None: # type: ignore[reportRedeclaration]
try:
_fcntl.flock(fd, _fcntl.LOCK_UN)
except OSError:
pass
except ImportError: # pragma: no cover — Windows path
def _try_flock(fd: int) -> None: # noqa: F841 — Windows fallback
return None
def _try_funlock(fd: int) -> None: # noqa: F841 — Windows fallback
return None
__all__ = [
"ACTION_OPERATOR_EDIT",
"AuditEntry",
"AuditStore",
"COMPONENT_FOR_TOOL",
"DEFAULT_POLL_INTERVAL_SEC",
"DB_PATH_IN_CONTAINER",
"Proposal",
"QUEUE_DIR_IN_CONTAINER",
"QueueStore",
"Response",
"StoreManager",
"STATUSES",
"STATUS_APPROVED",
"STATUS_MODIFIED",
@@ -536,8 +298,9 @@ __all__ = [
"audit_dir",
"audit_log_path",
"bot_bottle_root",
"host_db_path",
"list_pending_proposals",
"queue_dir_for_slug",
"list_all_pending_proposals",
"read_audit_entries",
"read_proposal",
"read_response",
+9 -17
View File
@@ -7,14 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
Each queued tool call:
1. Validates the proposed file syntactically.
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
the host's ~/.bot-bottle/queue/<slug>/).
3. Blocks polling for a matching Response file.
2. Writes a Proposal to the host SQLite database.
3. Blocks polling for a matching Response row.
4. Returns the operator's `{status, notes}` to the agent.
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
container creation by the backend's start step). The queue dir comes
from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`).
container creation by the backend's start step). SUPERVISE_DB_PATH
points at the bind-mounted host database.
Speaks MCP over HTTP+JSON-RPC. Methods handled:
@@ -42,7 +41,6 @@ import typing
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
try:
# Same-directory imports inside the bundle container; these files are
@@ -277,7 +275,6 @@ def validate_proposed_file(tool: str, content: str) -> None:
@dataclass(frozen=True)
class ServerConfig:
bottle_slug: str
queue_dir: Path
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
@@ -376,7 +373,7 @@ def handle_tools_call(
current_file_hash=_sv.sha256_hex(proposed_file),
)
try:
_sv.write_proposal(config.queue_dir, proposal)
_sv.write_proposal(proposal)
except OSError as e:
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
sys.stderr.write(
@@ -387,7 +384,7 @@ def handle_tools_call(
deadline = time.monotonic() + config.response_timeout_seconds
try:
response = _sv.wait_for_response(
config.queue_dir,
config.bottle_slug,
proposal.id,
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
deadline=deadline,
@@ -399,7 +396,7 @@ def handle_tools_call(
"isError": False,
}
try:
_sv.archive_proposal(config.queue_dir, proposal.id)
_sv.archive_proposal(config.bottle_slug, proposal.id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
@@ -539,7 +536,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path())
config: ServerConfig = ServerConfig(bottle_slug="")
# --- Entry point -----------------------------------------------------------
@@ -548,21 +545,18 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def serve(
*,
bottle_slug: str,
queue_dir: Path,
port: int = _sv.SUPERVISE_PORT,
bind: str = "0.0.0.0",
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
) -> typing.NoReturn:
queue_dir.mkdir(parents=True, exist_ok=True)
server = MCPServer((bind, port), MCPHandler)
server.config = ServerConfig(
bottle_slug=bottle_slug,
queue_dir=queue_dir,
response_timeout_seconds=response_timeout_seconds,
)
sys.stderr.write(
f"supervise listening on {bind}:{port}; "
f"slug={bottle_slug!r}; queue={queue_dir}; "
f"slug={bottle_slug!r}; "
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
)
sys.stderr.flush()
@@ -581,7 +575,6 @@ def main(argv: list[str]) -> int:
if not bottle_slug:
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
return 2
queue_dir = Path(os.environ.get("SUPERVISE_QUEUE_DIR", _sv.QUEUE_DIR_IN_CONTAINER))
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
try:
@@ -591,7 +584,6 @@ def main(argv: list[str]) -> int:
return 2
serve(
bottle_slug=bottle_slug,
queue_dir=queue_dir,
port=port,
bind=bind,
response_timeout_seconds=response_timeout_seconds,
+189
View File
@@ -0,0 +1,189 @@
"""Shared types and path helpers for supervise stores (PRD 0013).
Extracted from supervise.py so queue_store and audit_store can import
Proposal, Response, AuditEntry, and host_db_path without creating a
circular import (supervise imports from queue_store/audit_store and
vice-versa).
Patching bot_bottle_root on this module propagates through host_db_path
because host_db_path looks up bot_bottle_root via sys.modules[__name__]
at call time rather than capturing it at import time.
"""
from __future__ import annotations
import dataclasses
import sys
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
HOST_DB_FILENAME = "bot-bottle.db"
TOOL_EGRESS_BLOCK = "egress-block"
TOOL_EGRESS_ALLOW = "egress-allow"
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = (
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW,
TOOL_LIST_EGRESS_ROUTES,
)
STATUS_APPROVED = "approved"
STATUS_MODIFIED = "modified"
STATUS_REJECTED = "rejected"
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
ACTION_OPERATOR_EDIT = "operator-edit"
def bot_bottle_root() -> Path:
return Path.home() / ".bot-bottle"
def host_db_path() -> Path:
# Look up bot_bottle_root through this module's live namespace so that
# monkey-patches on supervise_types.bot_bottle_root take effect.
#
# Lives in its own "db" subdirectory, not directly under
# bot_bottle_root(), so backends that can only bind-mount
# directories (macos_container's `container run --mount`, which
# rejects file sources with "is not a directory") can share this
# one file with a sidecar without also exposing bot_bottle_root()'s
# other contents (git-gate keys, per-bottle state, etc.).
return sys.modules[__name__].bot_bottle_root() / "db" / HOST_DB_FILENAME
def _require_str(raw: dict[str, object], key: str) -> str:
value = raw.get(key)
if not isinstance(value, str):
raise ValueError(f"missing or non-string field {key!r}")
return value
@dataclass(frozen=True)
class Proposal:
"""One pending tool-call from the agent."""
id: str
bottle_slug: str
tool: str
proposed_file: str
justification: str
arrival_timestamp: str
current_file_hash: str
@classmethod
def new(
cls,
*,
bottle_slug: str,
tool: str,
proposed_file: str,
justification: str,
current_file_hash: str,
now: datetime | None = None,
) -> "Proposal":
ts = (now or datetime.now(timezone.utc)).isoformat()
return cls(
id=str(uuid.uuid4()),
bottle_slug=bottle_slug,
tool=tool,
proposed_file=proposed_file,
justification=justification,
arrival_timestamp=ts,
current_file_hash=current_file_hash,
)
def to_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, raw: dict[str, object]) -> "Proposal":
tool = _require_str(raw, "tool")
if tool not in TOOLS:
raise ValueError(f"tool must be one of {TOOLS}; got {tool!r}")
return cls(
id=_require_str(raw, "id"),
bottle_slug=_require_str(raw, "bottle_slug"),
tool=tool,
proposed_file=_require_str(raw, "proposed_file"),
justification=_require_str(raw, "justification"),
arrival_timestamp=_require_str(raw, "arrival_timestamp"),
current_file_hash=_require_str(raw, "current_file_hash"),
)
@dataclass(frozen=True)
class Response:
"""The operator's decision on a proposal."""
proposal_id: str
status: str
notes: str
final_file: str | None = None
def to_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, raw: dict[str, object]) -> "Response":
status = _require_str(raw, "status")
if status not in STATUSES:
raise ValueError(
f"response status must be one of {STATUSES}; got {status!r}"
)
final = raw.get("final_file")
if final is not None and not isinstance(final, str):
raise ValueError(
f"final_file must be a string or null; got {type(final).__name__}"
)
return cls(
proposal_id=_require_str(raw, "proposal_id"),
status=status,
notes=_require_str(raw, "notes"),
final_file=final,
)
@dataclass(frozen=True)
class AuditEntry:
"""One row of the per-bottle audit log."""
timestamp: str
bottle_slug: str
component: str
operator_action: str
operator_notes: str
justification: str
diff: str
def to_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
__all__ = [
"ACTION_OPERATOR_EDIT",
"AuditEntry",
"HOST_DB_FILENAME",
"Proposal",
"Response",
"STATUSES",
"STATUS_APPROVED",
"STATUS_MODIFIED",
"STATUS_REJECTED",
"TOOLS",
"TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK",
"TOOL_EGRESS_TOKEN_ALLOW",
"TOOL_GITLEAKS_ALLOW",
"TOOL_LIST_EGRESS_ROUTES",
"bot_bottle_root",
"host_db_path",
]
+135
View File
@@ -0,0 +1,135 @@
# PRD 0067: SQLite local storage
- **Status:** Active
- **Author:** codex
- **Created:** 2026-07-01
- **Issue:** #319
## Summary
Add a small stdlib SQLite storage layer for bot-bottle host runtime state,
starting with the supervise queue and audit log. This replaces scattered JSON
queue files and JSONL audit logs with structured tables while preserving the
existing public supervise helper functions and sidecar queue mount contract.
## Problem
Bot-bottle currently stores supervise proposals and responses as individual JSON
files under `~/.bot-bottle/queue/<slug>/`, and audit entries as JSONL files
under `~/.bot-bottle/audit/`. That worked for the original interactive TUI, but
new forge-native orchestration needs durable, queryable local state for queues,
audit trails, watchdogs, and lifecycle records. PR #318 started introducing
SQLite-shaped boilerplate for forge state; the storage foundation should live in
its own PR so forge work can build on the shared runtime store instead of adding
one-off persistence.
## Goals / Success Criteria
1. Supervise proposals and responses are persisted through SQLite.
2. Audit entries are persisted through SQLite.
3. Supervise queue helpers use the bottle slug / queue key instead of a queue
directory path.
4. The sidecar receives the host database mount across docker, smolmachines,
and macOS-container backends.
5. The implementation stays stdlib-only.
6. Schema migrations use a `PRAGMA user_version` runner — no third-party deps.
7. Unit tests cover queue round-trips, pending discovery, response waits,
archive semantics, audit round-trips, and path creation.
## Non-goals
- Migrating old JSON queue files or JSONL audit logs.
- Adding forge orchestration state tables.
- Adding egress metering or budget tables.
- Changing the supervise TUI workflow or remediation behavior.
- Introducing a third-party ORM or migration library.
## Design
### Database locations
Queue and audit state use the host-level local database:
```text
~/.bot-bottle/bot-bottle.db
```
The supervise sidecar receives that database as a writable bind mount at
`/run/supervise/bot-bottle.db` and gets the path through `SUPERVISE_DB_PATH`.
No per-slug queue directory is mounted into the sidecar. This creates the shared
host database that later forge/native lifecycle work can extend in separate
PRDs.
### Tables
`supervise_proposals` lives in the host database:
```sql
CREATE TABLE supervise_proposals (
queue_key TEXT NOT NULL,
id TEXT NOT NULL,
bottle_slug TEXT NOT NULL,
tool TEXT NOT NULL,
proposed_file TEXT NOT NULL,
justification TEXT NOT NULL,
arrival_timestamp TEXT NOT NULL,
current_file_hash TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (queue_key, id)
);
```
`supervise_responses` lives in the host database:
```sql
CREATE TABLE supervise_responses (
queue_key TEXT NOT NULL,
proposal_id TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT NOT NULL,
final_file TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (queue_key, proposal_id)
);
```
`supervise_audit_entries` lives in the host database:
```sql
CREATE TABLE supervise_audit_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
bottle_slug TEXT NOT NULL,
component TEXT NOT NULL,
operator_action TEXT NOT NULL,
operator_notes TEXT NOT NULL,
justification TEXT NOT NULL,
diff TEXT NOT NULL
);
```
### Compatibility
The queue helpers take a bottle slug / queue key and perform equivalent
operations against `~/.bot-bottle/bot-bottle.db`:
- `list_pending_proposals` returns non-archived proposals without a non-archived
response, sorted by arrival time.
- `archive_proposal` marks matching proposal/response rows archived instead of
moving files into `processed/`.
- `wait_for_response` keeps the current polling behavior but polls SQLite.
The old audit path helpers (`audit_dir`, `audit_log_path`) stay available for
compatibility. `audit_log_path` no longer describes the active storage location;
callers should use `read_audit_entries`.
## Implementation chunks
1. Add SQLite store helpers for supervise queue and audit state.
2. Rewire `bot_bottle.supervise` queue/audit functions to the store.
3. Update supervise CLI discovery tests and queue/audit unit tests.
4. Run unit tests, pyright, and pylint for touched modules.
## Open questions
None.
@@ -1,439 +0,0 @@
# PRD prd-new: Forge native integration
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-06-29
- **Issue:** #317
## Summary
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
drive bot-bottle sessions end-to-end with no operator in the loop for the happy
path. An issue assigned to a member of the configured agent org and labelled
with an agent name triggers a headless bottle launch; the bottle processes the
issue, opens a PR, and interacts with the forge through a **forge sidecar**
the agent never touches the Gitea API or its credentials directly. The agent
calls `signal_done(status, summary)` on the sidecar when a work unit is
complete; the sidecar relays that to the orchestrator over a queue dir (the same
pattern as the supervise sidecar), so completion is an unambiguous in-band
signal rather than a comment the orchestrator has to parse. The orchestrator
freezes the bottle. Subsequent PR comments rehydrate the frozen bottle. The
bottle is destroyed when the PR closes.
The forge sidecar is backed by a `Forge` abstract class with per-provider
implementations (Gitea first), so the agent's prompts and the sidecar protocol
stay forge-agnostic. The sidecar logs forge operations semantically ("read PR
description", "posted comment", "signalled done"), giving richer provenance than
post-hoc egress-byte parsing, and enforces a **read-anywhere / write-scoped**
permission model: the agent may read for context but may only write to the
issue and PRs it was assigned.
Run provenance is exposed through a **provenance API** (the sidecar's structured
operation log plus the run's metadata), not posted back into the forge. We do
not surface a provenance footer in the PR — the audit record lives behind the
API where it can be retained and queried, rather than as an editable comment.
The separation of concerns across the two layers: bot-bottle owns the headless
launch primitives, the forge sidecar + `Forge` abstraction, and forge state.
`bot-bottle-orchestrator` (separate binary) owns the webhook listener, bottle
lifecycle loop, and monitoring dashboard; it calls into bot-bottle via
`./cli.py orchestrate`, a thin wrapper command. This PRD covers bot-bottle's
side of that contract.
## Problem
Today an operator must open the TUI, select an agent and bottle, confirm the
preflight, and type prompts interactively. This blocks "issue → PR" automation
and produces no durable audit record of what the agent did. The security model
already provides the right isolation and egress controls, and `start --headless`
(#315) already gives `bot-bottle-orchestrator` a non-interactive launch path.
The missing pieces are a headless `resume` counterpart for rehydrating frozen
bottles, a forge-interaction surface the agent uses to read context, post
comments, and signal completion, and the provenance trail that makes the audit
story legible to reviewers on every PR.
That forge-interaction surface could be built two ways: (2) give the agent the
Gitea API directly with cred-proxy injecting the token, or (3) put a forge
sidecar between the agent and the forge. This PRD takes **option 3**. The
deciding factors: a sidecar `signal_done` call is an unambiguous completion
signal where comment-parsing is a correctness risk that surfaces in production;
the sidecar produces a semantic audit trail rather than HTTP bytes, which is
load-bearing for provenance (the stated product priority); and the sidecar can
enforce scope tighter than repo-wide API-key permissions, reducing blast radius
for a prompt-injected agent. The costs — a second sidecar process per forge run,
a new failure mode if it crashes, and per-forge implementation cost — are
accepted as the price of those properties.
## Goals / Success Criteria
1. Headless launch already exists: `./cli.py start <agent> --headless --prompt`
(#315) runs non-interactively with no TUI selectors or y/N preflight. This
PRD builds on it rather than re-introducing it. The remaining gap is a
matching headless `resume` path (`./cli.py resume --headless`), since
rehydrating a frozen bottle for a new prompt is required by the freeze /
rehydrate loop and `resume` has no non-interactive entry point today.
2. An issue assigned to a member of the configured org (`FORGE_ORG`, default
`bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
convention. Org membership is verified via the Gitea API at event time.
3. Forge-targeted bottles run a **forge sidecar** that exposes a small,
forge-agnostic API (comment/issue/PR CRUD plus `signal_done`) over the same
queue-dir + HTTP/JSON-RPC machinery as the supervise sidecar. The agent calls
the sidecar; it never sees the forge token or forge-specific endpoints.
4. The sidecar is backed by a `Forge` abstract class. Gitea is the first
concrete implementation; adding a forge means a new subclass, not changes to
the agent prompt or sidecar protocol. The sidecar enforces a read-anywhere /
write-scoped model: writes are limited to the assigned issue and its PRs;
reads are unrestricted for context.
5. The agent calls `signal_done(status, summary)` on the sidecar when a work
unit is complete; the sidecar relays it to the orchestrator over a queue dir.
This is the done signal — no comment parsing. A watchdog timeout
(configurable, default 30 min) causes the orchestrator to treat the run as
done-without-self-report if the agent exits without signalling.
6. Run provenance (agent name, bottle name(s), slug, timing, exit code,
gitleaks result, egress summary, and the sidecar's semantic operation log)
is available through a provenance API. It is **not** surfaced as a PR footer
or any other forge comment.
7. Forge state (issue → slug, status) is persisted in a local SQLite database
under `~/.bot-bottle/` and survives orchestrator restarts.
8. `./cli.py orchestrate status` lists active forge-managed bottles and their
issue/PR URLs.
9. Unit tests cover: label parsing, org-membership check path, forge state
store CRUD (SQLite), headless launch arg construction, forge env var
injection, sidecar request dispatch through the `Forge` abstraction,
write-scope enforcement (reject writes outside the assigned issue/PRs), and
`signal_done` queue relay.
## Non-goals
- Webhook signature verification (HMAC-SHA256). Added as a follow-up.
- The `bot-bottle-orchestrator` binary itself — this PRD covers bot-bottle's
side of the interface only. The orchestrator is a separate project.
- GitHub or GitLab support.
- Multiple simultaneous forge bottles per issue.
- Automatic retry on agent error exit.
- Bottle destruction on issue close (PR close only; issue close is ambiguous).
- Concurrent multi-issue handling (one blocking run per orchestrator process).
- A monitoring dashboard (orchestrator-side concern).
- Folding `DeployKeyProvisioner` into the `Forge` abstraction. Deploy-key
provisioning runs at bottle-provision time on the host; the forge sidecar runs
inside the bottle at agent time. The two have different lifecycles and actors,
so coupling them into one class is deferred to a follow-up. This PRD only
shares the Gitea HTTP client between them.
## Design
### Targeting convention
An issue is forge-targeted when **both** hold:
- At least one assignee is a member of the Gitea org named by `FORGE_ORG`
(default `bot-bottle`). Checked via `GET /api/v1/orgs/{org}/members/{user}`.
- At least one label has the prefix `bot-bottle:`. The suffix names the agent
manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
`FORGE_ORG` is read at orchestrate-command startup. It is not embedded in
manifests or state files; the orchestrator stamps its value into log output for
auditability.
An optional label `bot-bottle-bottle:<name>` overrides bottle selection. When
absent the agent's default bottle is used.
### `./cli.py orchestrate` — the thin wrapper
```
./cli.py orchestrate start --agent AGENT [--bottle BOTTLE ...] --prompt PROMPT
[--label LABEL] [--backend BACKEND]
./cli.py orchestrate resume --slug SLUG --prompt PROMPT [--backend BACKEND]
./cli.py orchestrate status
```
`orchestrate start` is a thin shim over the already-shipped `start --headless`
(#315): it forwards agent / bottle / label / prompt and adds the forge-specific
wiring (`forge_env`, sidecar launch). It does not re-implement headless launch.
The caller (`bot-bottle-orchestrator`) manages freeze, state, and the forge
sidecar's done signal around it.
`orchestrate resume` is the shim over the new `resume --headless` (below).
`orchestrate status` prints the forge state table.
### Headless primitives — what exists vs. what's new
Headless **start** already shipped in #315 and this PRD reuses it as-is:
- `./cli.py start <agent> --headless --prompt TEXT` — no TUI selectors, no y/N
preflight. Internally `_start_headless()` calls the shared `_launch_bottle()`
with `assume_yes=True` and `headless_prompt_text=prompt`.
- The prompt is delivered through `AgentProvider.headless_prompt(prompt)`
claude `-p`, codex positional, pi `-p`. The orchestrator does **not** hand-roll
agent args; it relies on this provider abstraction. (An earlier draft proposed
`start_headless` / `attach_agent_headless` helpers that constructed
`--no-interactive`/`-p` directly — those are dropped as redundant with, and
divergent from, what #315 merged.)
Two additions are needed on top of #315:
**1. A `forge_env` hook on the headless launch path.** The orchestrator needs to
pass forge context + token through to the forge sidecar launched alongside the
agent. This is a parameter threaded into `_launch_bottle` (the same core
`start --headless` already uses), not a parallel launch function. The agent
process itself does not receive the token.
**2. `resume --headless`** — new in `bot_bottle/cli/resume.py`, mirroring the
`--headless` flag on `start`:
```
./cli.py resume <slug> --headless --prompt TEXT
```
It rehydrates a frozen bottle and runs one headless prompt via the same
`assume_yes` + `headless_prompt` path, returning the agent's exit code. `resume`
has no non-interactive entry point today, so this is genuinely new work rather
than a rename of an existing helper.
### Forge sidecar
Forge-targeted bottles run a forge sidecar alongside the agent, mirroring the
supervise sidecar: a per-bottle process that exposes an HTTP/JSON-RPC endpoint
over a Unix socket and relays events to the orchestrator through a queue dir.
The agent calls the sidecar; the sidecar holds the forge token and makes the
actual forge API calls. The agent never receives the credential and never sees a
forge-specific endpoint — swapping Gitea for another forge does not change the
agent prompt or the sidecar protocol.
The sidecar is configured at launch from the forge context (owner, repo, issue,
PR) and the token, supplied by the orchestrator — not baked into the agent
manifest. Because the sidecar owns the token, forge traffic does not need a
cred-proxy egress route on the agent; the agent's egress policy is unchanged by
forge targeting.
**Sidecar protocol** (forge-agnostic; each method maps to a `Forge` call):
| Method | Scope | Purpose |
|---|---|---|
| `read_issue(number)` | read-anywhere | Read an issue body for context |
| `read_pr(number)` | read-anywhere | Read a PR (incl. merge state) for context |
| `read_comments(number)` | read-anywhere | Read a thread for context |
| `post_comment(number, body)` | write-scoped | Post to the assigned issue/PR |
| `update_description(number, body)` | write-scoped | Edit the assigned issue/PR body |
| `signal_done(status, summary)` | — | Relay completion to the orchestrator |
Issues and PRs are distinct domain objects (`Issue` vs `PullRequest`) read
through distinct methods; a PR carries merge state an issue does not.
**Scope enforcement** is read-anywhere / write-scoped: read methods accept any
issue/PR number for context; write methods are rejected unless the target is the
assigned issue or one of its PRs. This is tighter than Gitea's repo-wide API-key
permissions and bounds the blast radius of a prompt-injected agent. Rejections
are logged semantically (operation, target, reason) so the audit trail records
attempted out-of-scope writes, not just allowed ones.
**Semantic audit**: every sidecar call is logged as a structured operation
("read PR #318 description", "posted comment to #317", "signalled done:
success") rather than as opaque HTTP bytes. This log feeds provenance directly,
with no post-hoc egress-log parsing.
### `Forge` abstraction — `bot_bottle/contrib/forge/`
The sidecar dispatches to a `Forge` abstract class. Each provider implements the
operations behind the sidecar protocol:
```python
class Forge(abc.ABC):
@abc.abstractmethod
def read_issue(self, number: int) -> Issue: ...
@abc.abstractmethod
def read_pr(self, number: int) -> PullRequest: ...
@abc.abstractmethod
def read_comments(self, number: int) -> list[Comment]: ...
@abc.abstractmethod
def post_comment(self, number: int, body: str) -> None: ...
@abc.abstractmethod
def update_description(self, number: int, body: str) -> None: ...
@abc.abstractmethod
def is_org_member(self, org: str, username: str) -> bool: ...
@abc.abstractmethod
def get_pr_for_issue(self, number: int) -> int | None: ...
@abc.abstractmethod
def is_pr_open(self, number: int) -> bool: ...
```
`Issue` and `PullRequest` are separate frozen dataclasses — a PR adds `merged`.
`ScopedForge` wraps a concrete `Forge` to enforce the read-anywhere /
write-scoped model (`post_comment` / `update_description` raise `ForgeScopeError`
outside the assigned issue and PRs).
`GiteaForge` is the first and only concrete implementation in this PRD. It wraps
the Gitea HTTP client (below). Adding GitHub or GitLab later is a new subclass;
the sidecar, protocol, and agent prompt are untouched.
> **Deferred:** `DeployKeyProvisioner` is *not* folded into `Forge` here.
> Deploy-key provisioning runs on the host at provision time; the sidecar runs
> in the bottle at agent time. They have different lifecycles and actors, so a
> shared abstract base would couple two unrelated auth contexts. For now they
> only share the Gitea HTTP client; a later PRD can revisit unification.
### Forge env vars
The orchestrator passes forge context to the **sidecar** (not the agent) at
launch. The agent does not need owner/repo/issue env vars to construct API
calls, since it only names issue/PR numbers to the sidecar:
| Var | Example | Purpose |
|---|---|---|
| `FORGE_GITEA_API` | `https://gitea.dideric.is/api/v1` | Base URL the sidecar calls |
| `FORGE_OWNER` | `didericis` | Repo owner |
| `FORGE_REPO` | `bot-bottle` | Repo name |
| `FORGE_ISSUE_NUMBER` | `317` | Assigned issue (defines write scope) |
| `FORGE_PR_NUMBER` | `318` | Assigned PR (empty until PR exists) |
The agent's forge-specific prompt instructs it to call `signal_done` on the
sidecar when a work unit is complete, and to use the sidecar for any
comment/description writes. The instruction is forge-agnostic and is part of the
forge prompt overlay, not the base agent manifest, so non-forge runs are
unaffected.
### Done signal and watchdog
The agent calls `signal_done(status, summary)` on the sidecar when it finishes a
work unit. The sidecar writes the event to its queue dir; the orchestrator reads
it and:
1. Reads the forge state for `(owner, repo, issue_number)`.
2. If `status == "running"`, treats the event as the done signal: freezes the
bottle and sets `status = "frozen"`. Provenance is recorded via the
provenance API — no comment is posted to the forge.
Because completion is an explicit `signal_done` call, the orchestrator does not
parse comment text to detect "done", and intermediate comments the agent posts
mid-run cannot be mistaken for completion.
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state, updated
on each sidecar event. A background thread wakes every minute. If
`now - last_checkin_at > FORGE_WATCHDOG_TIMEOUT` (default 30 min, configurable
via env) and `status == "running"`, the orchestrator treats the run as
done-without-self-report and freezes the bottle, flagging the run as incomplete
in the provenance record.
**Sidecar-death failure mode**: if the forge sidecar crashes mid-run the agent
loses forge access while the bottle is otherwise healthy. The orchestrator
detects a dead sidecar (socket/queue gone) the same way it detects a stalled
agent and falls back to the watchdog path.
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
State is stored in a local SQLite database at `~/.bot-bottle/bot-bottle.db`.
Access goes through a thin CRUD interface, `ForgeStateStore`, so the storage
location/engine can be swapped without touching callers. `SqliteForgeStateStore`
is the first implementation.
The `forge_state` table is keyed by `(owner, repo, issue_number)` and carries:
`slug`, `agent_name`, `bottle_names` (JSON), `backend_name`, `agent_git_user`,
`pr_number` (nullable), `status`, `last_checkin_at`.
`status`: `"running"` | `"frozen"` | `"destroyed"`.
Store interface:
```python
class ForgeStateStore(abc.ABC):
def upsert(self, state: ForgeState) -> None: ...
def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None: ...
def delete(self, owner: str, repo: str, issue_number: int) -> None: ...
def all(self) -> list[ForgeState]: ...
class SqliteForgeStateStore(ForgeStateStore):
def __init__(self, db_path: Path | None = None) -> None: ...
```
`upsert` uses `INSERT OR REPLACE` so a re-run for the same issue overwrites in
place. The schema is created on first open.
### Provenance API
Run provenance — agent, bottle(s), slug, timing, exit code, gitleaks result,
egress summary, watchdog-fired flag, and the sidecar's semantic operation log —
is exposed through a **provenance API**, not posted into the forge. There is no
provenance footer or run-summary comment.
The rationale (per the monetization positioning): a PR comment is mutable by any
maintainer, unsigned, and per-PR, so it is worthless as an audit record and
invites false trust. The authoritative record therefore lives behind the API,
where it can be retained, queried, and (eventually) signed. Whether any
projection of it ever appears in the forge is a separate, out-of-scope decision;
this PR does not build one.
The API surface itself (schema, transport, signing, retention) is **out of scope
for this PRD** and belongs with the orchestrator / control-plane work. bot-bottle
here only produces the raw material: the sidecar's semantic operation log and the
run metadata the orchestrator collects.
### Gitea HTTP client — `bot_bottle/contrib/gitea/client.py`
`GiteaForge` (and the existing `GiteaDeployKeyProvisioner`) share one thin HTTP
client. Unlike the option-2 design, the token is held by the sidecar process and
passed to the client directly — there is no agent-side cred-proxy route to
inject it, because the agent never makes forge calls.
```python
class GiteaClient:
def __init__(self, *, api_url: str, owner: str, repo: str, token: str) -> None: ...
def is_org_member(self, org: str, username: str) -> bool: ...
def get_issue(self, number: int) -> dict: ...
def get_comments(self, number: int) -> list[dict]: ...
def post_comment(self, number: int, body: str) -> None: ...
def patch_issue_body(self, number: int, body: str) -> None: ...
def get_pull(self, number: int) -> dict: ...
```
`GiteaForge` adapts this client to the `Forge` surface (mapping raw JSON to
`Issue` / `PullRequest` / `Comment`). Sharing only the HTTP client (not an
abstract base) is the deliberate boundary between the sidecar and the deploy-key
provisioner — see the deferral note under the `Forge` abstraction.
### Implementation chunks
1. **Headless additions on top of #315** — thread a `forge_env` parameter into
the existing `_launch_bottle` core (the one `start --headless` already uses);
add a `--headless` path to `cli/resume.py` reusing `assume_yes` +
`headless_prompt`. No new `start_headless`/`attach_agent_headless` helpers.
Tests: `forge_env` reaches the sidecar/`guest_env`; `resume --headless` skips
the TUI and y/N preflight and returns the agent exit code.
2. **Forge state**`contrib/gitea/forge_state.py`: `ForgeState` dataclass,
`ForgeStateStore` CRUD interface, `SqliteForgeStateStore`. Tests: round-trip,
missing → None, `INSERT OR REPLACE` upsert, delete idempotent, `all()`
ordering, persistence across store instances.
3. **`Forge` abstraction + Gitea client** — `contrib/forge/base.py` (`Forge`
ABC, `ScopedForge`, `Issue` / `PullRequest` / `Comment`) and
`contrib/gitea/client.py` + `GiteaForge`: `is_org_member`, `read_issue`,
`read_pr`, `read_comments`, `post_comment`, `update_description`,
`get_pr_for_issue`, `is_pr_open`. Tests: mock `urllib.request.urlopen`,
assert payloads and 404-as-false for membership; `ScopedForge` write-scope
enforcement.
4. **Forge sidecar** — sidecar process exposing the protocol over a Unix socket,
queue-dir relay, write-scope enforcement, semantic op log, `signal_done`.
Reuses the supervise sidecar bundle machinery. Tests: dispatch each method to
the `Forge`, reject out-of-scope writes, `signal_done` writes a queue event,
scope-rejection is logged.
5. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
`status` subcommands wired into `cli.py`; `start` launches the forge sidecar
alongside the agent for forge-targeted runs. Tests: arg parsing, `start`
delegates to `start --headless`, `resume` delegates to `resume --headless`.
## Provenance
Run provenance is captured (sidecar semantic operation log + run metadata) and
exposed through a provenance API. It is deliberately **not** surfaced in the
forge — no footer, no run-summary comment. A mutable, unsigned PR comment is not
an audit record; the authoritative record lives behind the API where it can be
retained and signed. The `watchdog_fired` flag marks runs where the agent did
not self-report completion so consumers of the API know the record may be
incomplete.
The provenance API's schema, transport, signing, and retention are out of scope
for this PRD (control-plane work); bot-bottle here produces the raw material
only.
+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
+3 -3
View File
@@ -81,10 +81,10 @@ class TestSandboxEscape(unittest.TestCase):
# those are missing rather than die-ing inside backend.prepare.
backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
if backend_name == "smolmachines":
if sys.platform != "darwin":
if sys.platform not in ("darwin", "linux"):
raise unittest.SkipTest(
"BOT_BOTTLE_BACKEND=smolmachines is macOS-only in "
"v1 (libkrun TSI)"
f"BOT_BOTTLE_BACKEND=smolmachines is not supported "
f"on {sys.platform} (macOS and Linux only)"
)
if shutil.which("smolvm") is None:
raise unittest.SkipTest(
+12 -4
View File
@@ -21,7 +21,7 @@ security properties the design pivot was about:
bind-address mitigation is what closes TSI's port-granularity
gap.
Gated on macOS + smolvm + docker + not GITEA_ACTIONS the
Gated on macOS/Linux + smolvm + docker + not GITEA_ACTIONS the
runner can't host libkrun-backed VMs."""
from __future__ import annotations
@@ -65,8 +65,8 @@ def _minimal_manifest() -> ManifestIndex:
@skip_unless_docker()
@unittest.skipUnless(
platform.system() == "Darwin",
"smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
platform.system() in ("Darwin", "Linux"),
"smolvm requires macOS or Linux",
)
@unittest.skipUnless(
_smolvm_available(),
@@ -141,7 +141,15 @@ class TestSmolmachinesLaunch(unittest.TestCase):
proxies = [line.strip() for line in r.stdout.splitlines()]
self.assertEqual(2, len(proxies), proxies)
self.assertEqual(proxies[0], proxies[1], proxies)
self.assertTrue(proxies[0].startswith("http://127."), proxies[0])
# macOS: proxy binds to the per-bottle loopback alias (127.x.x.x) so
# TSI can intercept guest connections to it. Linux: the guest kernel
# routes 127.0.0.0/8 to its own loopback (TSI never sees those), so
# the proxy instead binds to the per-bottle bridge gateway (192.168.x.1)
# which routes via eth0 and is intercepted by TSI normally.
self.assertRegex(
proxies[0], r"^http://\d+\.\d+\.\d+\.\d+:\d+$",
"expected proxy URL to be an http://IP:port address",
)
r = self.bottle.exec(
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
@@ -2,7 +2,7 @@
exercised against the real binary.
The full machine-lifecycle round trip (create start exec
delete) is gated behind macOS + Darwin platform check and lives
delete) is gated behind macOS/Linux platform check and lives
in chunk 2d's smoke. This file just verifies `is_available()`
correctly reports presence and `_smolvm()` can run a no-op
subcommand without errors enough to flag wrapper drift if
@@ -23,8 +23,8 @@ from bot_bottle.backend.smolmachines.smolvm import is_available
"skipped under act_runner: smolvm not installed on the runner",
)
@unittest.skipUnless(
platform.system() == "Darwin",
"smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
platform.system() in ("Darwin", "Linux"),
"smolvm requires macOS or Linux",
)
@unittest.skipUnless(
is_available(),
+4 -1
View File
@@ -35,7 +35,10 @@ class TestAgentProviderRuntime(unittest.TestCase):
)
config = Path(tmp, "codex-config.toml").read_text()
self.assertEqual("codex", plan.template)
self.assertEqual("codex", plan.command)
self.assertEqual(
"/home/node/.codex/packages/standalone/current/bin/codex",
plan.command,
)
self.assertEqual("read_prompt_file", plan.prompt_mode)
self.assertEqual("/tmp/Dockerfile.codex", plan.dockerfile)
self.assertEqual(
+17
View File
@@ -12,11 +12,19 @@ from unittest.mock import patch
import bot_bottle.cli as climod
from bot_bottle.cli import main
from bot_bottle.db_store import DbStore
from bot_bottle.log import Die
from bot_bottle.manifest import ManifestError
from bot_bottle.store_manager import StoreManager
class TestMainDispatch(unittest.TestCase):
def setUp(self) -> None:
patcher = patch.object(DbStore, "is_migrated", return_value=True)
self._mock_check = patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(StoreManager.reset)
def test_no_args_prints_usage_returns_2(self) -> None:
with patch("sys.stderr", io.StringIO()):
self.assertEqual(2, main([]))
@@ -56,6 +64,15 @@ class TestMainDispatch(unittest.TestCase):
main(["x", "a", "b"])
self.assertEqual([["a", "b"]], seen)
def test_missing_env_var_error_maps_to_1(self) -> None:
from bot_bottle.errors import MissingEnvVarError
def boom(_rest: list[str]) -> int:
raise MissingEnvVarError("MY_VAR", "MY_VAR is not set")
with patch.dict(climod.COMMANDS, {"x": boom}), patch("sys.stderr", io.StringIO()):
self.assertEqual(1, main(["x"]))
def test_manifest_error_maps_to_1(self) -> None:
def boom(_rest: list[str]) -> int:
raise ManifestError("bad manifest")
-75
View File
@@ -1,75 +0,0 @@
"""Unit: `cli.py resume --headless` non-interactive rehydrate path.
The freeze / rehydrate loop needs a non-interactive `resume`: deliver a
follow-up prompt and skip the y/N preflight, reusing the same launch
core (`assume_yes` + `headless_prompt_text`) as `start --headless`.
"""
from __future__ import annotations
import unittest
from typing import Any
from unittest.mock import MagicMock, patch
import bot_bottle.cli.resume as resume_mod
from bot_bottle.log import Die
def _metadata():
md = MagicMock()
md.agent_name = "implementer"
md.copy_cwd = False
md.cwd = "/repo"
md.identity = "implementer-abc12"
md.bottle_names = ["claude"]
md.backend = "docker"
return md
class ResumeHeadlessTest(unittest.TestCase):
def setUp(self) -> None:
self._launch = patch.object(
resume_mod, "_launch_bottle", return_value=0
).start()
patch.object(
resume_mod, "read_metadata", return_value=_metadata()
).start()
manifest = MagicMock()
manifest.require_agent = MagicMock(return_value=None)
patch.object(
resume_mod.ManifestIndex, "resolve", return_value=manifest
).start()
self.addCleanup(patch.stopall)
def _launch_kwargs(self) -> dict[str, Any]:
self._launch.assert_called_once()
return dict(self._launch.call_args.kwargs)
def test_headless_passes_assume_yes_and_prompt(self):
rc = resume_mod.cmd_resume(
["implementer-abc12", "--headless", "--prompt", "Address the review"]
)
self.assertEqual(0, rc)
kwargs = self._launch_kwargs()
self.assertTrue(kwargs["assume_yes"])
self.assertEqual("Address the review", kwargs["headless_prompt_text"])
def test_interactive_resume_unchanged(self):
resume_mod.cmd_resume(["implementer-abc12"])
kwargs = self._launch_kwargs()
self.assertFalse(kwargs["assume_yes"])
self.assertEqual("", kwargs["headless_prompt_text"])
def test_headless_without_prompt_errors(self):
with self.assertRaises(Die):
resume_mod.cmd_resume(["implementer-abc12", "--headless"])
self._launch.assert_not_called()
def test_prompt_without_headless_errors(self):
with self.assertRaises(Die):
resume_mod.cmd_resume(["implementer-abc12", "--prompt", "hi"])
self._launch.assert_not_called()
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -9,6 +9,7 @@ is created.
from __future__ import annotations
import io
import os
import unittest
from unittest.mock import MagicMock, patch
@@ -184,5 +185,55 @@ class TestCmdStartHeadless(unittest.TestCase):
self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"])
class TestPrepareWithPreflight(unittest.TestCase):
"""prepare_with_preflight calls render_preflight with the plan and backend name."""
def test_render_preflight_called(self):
from pathlib import Path
mock_backend = MagicMock()
mock_plan = MagicMock()
mock_backend.prepare.return_value = mock_plan
mock_backend.name = "test-backend"
render = MagicMock()
with patch("bot_bottle.cli.start.get_bottle_backend", return_value=mock_backend), \
patch("bot_bottle.cli.start._identity_from_plan", return_value="id"), \
patch("bot_bottle.cli.start.info"):
start_mod.prepare_with_preflight(
MagicMock(),
stage_dir=Path("/tmp"),
render_preflight=render,
prompt_yes=MagicMock(),
dry_run=True,
)
render.assert_called_once_with(mock_plan, "test-backend")
class TestNoAgentsDefined(unittest.TestCase):
"""cmd_start prints an error and returns 1 when the manifest has no agents."""
def test_no_agents_defined_returns_1(self):
manifest = _make_manifest([], [])
with patch(
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest
), patch("sys.stderr", io.StringIO()) as err:
rc = start_mod.cmd_start([])
self.assertEqual(1, rc)
self.assertIn("no agents defined", err.getvalue())
class TestTextRenderPreflight(unittest.TestCase):
"""_text_render_preflight returns a renderer that prints the backend name."""
def test_backend_name_in_output(self):
render = start_mod._text_render_preflight()
plan = MagicMock()
with patch("bot_bottle.cli.start._manifest_to_yaml", return_value=""), \
patch("sys.stderr", io.StringIO()) as err:
render(plan, "my-backend")
self.assertIn("my-backend", err.getvalue())
if __name__ == "__main__":
unittest.main()
+3 -4
View File
@@ -107,7 +107,7 @@ def _egress_plan(
def _supervise_plan() -> SupervisePlan:
return SupervisePlan(
slug=SLUG,
queue_dir=STATE / "supervise" / "queue",
db_path=STATE / "bot-bottle.db",
internal_network=f"bot-bottle-net-{SLUG}",
)
@@ -392,7 +392,7 @@ class TestSidecarBundleShape(unittest.TestCase):
sc = self._render(supervise=True)["services"]["sidecars"]
env_strings = sc["environment"]
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings)
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
def test_volumes_always_includes_egress_ca(self):
@@ -408,8 +408,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("/etc/egress", targets)
self.assertIn("/git-gate-entrypoint.sh", targets)
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
for t in targets))
self.assertIn("/run/supervise/bot-bottle.db", targets)
def test_extra_hosts_omitted_for_git_upstreams(self):
sc = self._render(with_git=True)["services"]["sidecars"]
+1 -1
View File
@@ -74,7 +74,7 @@ def _plan(
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
db_path=Path("/tmp/bot-bottle.db"),
)
return DockerBottlePlan(
spec=spec,
+33 -2
View File
@@ -77,7 +77,7 @@ def _plan(
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
db_path=Path("/tmp/bot-bottle.db"),
)
return DockerBottlePlan(
spec=spec,
@@ -261,6 +261,36 @@ class TestCodexProvision(unittest.TestCase):
self.assertTrue(any("find" in s and "-delete" in s for s in scripts))
self.assertTrue(any("runuser" in s and "codex login status" in s for s in scripts))
def test_forwarded_credentials_verify_sets_codex_path(self):
with tempfile.TemporaryDirectory(prefix="bb-codex-auth.") as tmp:
state_dir = Path(tmp)
prompt_file = state_dir / "prompt.txt"
prompt_file.write_text("")
with patch(
"bot_bottle.contrib.codex.agent_provider.codex_host_access_token",
return_value="token",
), patch(
"bot_bottle.contrib.codex.agent_provider.write_codex_dummy_auth_file",
):
provision = CodexAgentProvider().provision_plan(
dockerfile="",
state_dir=state_dir,
instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file,
forward_host_credentials=True,
host_env={},
)
verify_argv = provision.verify[0].argv
self.assertTrue(any(
item.startswith("PATH=/home/node/.local/bin:")
for item in verify_argv
))
self.assertIn(
"/home/node/.codex/packages/standalone/current/bin/codex",
verify_argv,
)
def test_dies_when_dir_creation_fails(self):
provision = AgentProvisionPlan(
template="codex", command="codex",
@@ -301,7 +331,8 @@ class TestCodexSuperviseMcp(unittest.TestCase):
script = bottle.exec.call_args.args[0]
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
self.assertEqual(
f"codex mcp add supervise --url {_URL}",
"/home/node/.codex/packages/standalone/current/bin/codex "
f"mcp add supervise --url {_URL}",
script,
)
-107
View File
@@ -1,107 +0,0 @@
"""Unit: Forge abstraction + ScopedForge (PRD forge-native-integration)."""
from __future__ import annotations
import unittest
from bot_bottle.contrib.forge.base import (
Comment,
Forge,
ForgeScopeError,
Issue,
PullRequest,
ScopedForge,
)
class _RecordingForge(Forge):
"""In-memory fake that records writes."""
def __init__(self) -> None:
self.comments: list[tuple[int, str]] = []
self.descriptions: list[tuple[int, str]] = []
def read_issue(self, number: int) -> Issue:
return Issue(number=number, title="t", body="b", state="open")
def read_pr(self, number: int) -> PullRequest:
return PullRequest(
number=number, title="pr", body="b", state="open", merged=False
)
def read_comments(self, number: int) -> list[Comment]:
return [Comment(id=1, user="alice", body="hi")]
def post_comment(self, number: int, body: str) -> None:
self.comments.append((number, body))
def update_description(self, number: int, body: str) -> None:
self.descriptions.append((number, body))
def is_org_member(self, org: str, username: str) -> bool:
return username == "member"
def get_pr_for_issue(self, number: int) -> int | None:
return 99 if number == 17 else None
def is_pr_open(self, number: int) -> bool:
return True
class TestScopedForgeReads(unittest.TestCase):
def setUp(self) -> None:
self.inner = _RecordingForge()
self.scoped = ScopedForge(self.inner, assigned_issue=17, assigned_prs=[42])
def test_reads_pass_through_to_any_number(self):
# A number well outside the writable scope still reads fine.
self.assertEqual(123, self.scoped.read_issue(123).number)
self.assertEqual("alice", self.scoped.read_comments(500)[0].user)
def test_read_pr_passes_through(self):
pr = self.scoped.read_pr(999)
self.assertIsInstance(pr, PullRequest)
self.assertEqual(999, pr.number)
self.assertFalse(pr.merged)
def test_membership_and_pr_lookups_delegate(self):
self.assertTrue(self.scoped.is_org_member("bot-bottle", "member"))
self.assertFalse(self.scoped.is_org_member("bot-bottle", "stranger"))
self.assertEqual(99, self.scoped.get_pr_for_issue(17))
self.assertTrue(self.scoped.is_pr_open(8000))
class TestScopedForgeWrites(unittest.TestCase):
def setUp(self) -> None:
self.inner = _RecordingForge()
self.scoped = ScopedForge(self.inner, assigned_issue=17, assigned_prs=[42])
def test_writable_set_is_issue_plus_prs(self):
self.assertEqual(frozenset({17, 42}), self.scoped.writable)
def test_write_to_assigned_issue_allowed(self):
self.scoped.post_comment(17, "done")
self.assertEqual([(17, "done")], self.inner.comments)
def test_write_to_assigned_pr_allowed(self):
self.scoped.update_description(42, "new body")
self.assertEqual([(42, "new body")], self.inner.descriptions)
def test_comment_outside_scope_rejected(self):
with self.assertRaises(ForgeScopeError) as ctx:
self.scoped.post_comment(500, "spam")
self.assertIn("500", str(ctx.exception))
self.assertEqual([], self.inner.comments)
def test_description_outside_scope_rejected(self):
with self.assertRaises(ForgeScopeError):
self.scoped.update_description(500, "tamper")
self.assertEqual([], self.inner.descriptions)
def test_scope_error_is_permission_error(self):
# Sidecars can catch the stdlib base type.
self.assertIn(PermissionError, ForgeScopeError.__mro__)
if __name__ == "__main__":
unittest.main()
-145
View File
@@ -1,145 +0,0 @@
"""Unit: GiteaClient + GiteaForge (PRD forge-native-integration)."""
from __future__ import annotations
import json
import unittest
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
from bot_bottle.contrib.gitea.client import GiteaClient, GiteaForge
def _client() -> GiteaClient:
return GiteaClient(
api_url="https://gitea.example.com/api/v1",
owner="didericis",
repo="bot-bottle",
token="test-token",
)
def _resp(body: object, status: int = 200) -> MagicMock:
resp = MagicMock()
resp.read.return_value = json.dumps(body).encode() if body is not None else b""
resp.status = status
resp.__enter__ = lambda s: s # type: ignore
resp.__exit__ = MagicMock(return_value=False)
return resp
def _http_error(code: int, body: str = "") -> urllib.error.HTTPError:
return urllib.error.HTTPError(
url="http://x", code=code, msg="err", hdrs=None, # type: ignore[arg-type]
fp=BytesIO(body.encode()),
)
_URLOPEN = "bot_bottle.contrib.gitea.client.urllib.request.urlopen"
class TestOrgMembership(unittest.TestCase):
def test_member_returns_true_on_2xx(self):
with patch(_URLOPEN, return_value=_resp(None, 204)) as m:
self.assertTrue(_client().is_org_member("bot-bottle", "alice"))
req = m.call_args.args[0]
self.assertIn("/orgs/bot-bottle/members/alice", req.full_url)
def test_nonmember_returns_false_on_404(self):
with patch(_URLOPEN, side_effect=_http_error(404)):
self.assertFalse(_client().is_org_member("bot-bottle", "stranger"))
def test_other_http_error_raises(self):
with patch(_URLOPEN, side_effect=_http_error(403, "forbidden")):
with self.assertRaises(RuntimeError) as ctx:
_client().is_org_member("bot-bottle", "alice")
self.assertIn("403", str(ctx.exception))
class TestForgeReads(unittest.TestCase):
def test_read_issue_maps_fields(self):
raw = {"number": 17, "title": "Bug", "body": "broken", "state": "open"}
with patch(_URLOPEN, return_value=_resp(raw)) as m:
issue = GiteaForge(_client()).read_issue(17)
self.assertEqual((17, "Bug", "broken", "open"),
(issue.number, issue.title, issue.body, issue.state))
self.assertIn("/repos/didericis/bot-bottle/issues/17",
m.call_args.args[0].full_url)
def test_read_issue_tolerates_null_body(self):
raw = {"number": 17, "title": "T", "body": None, "state": "open"}
with patch(_URLOPEN, return_value=_resp(raw)):
self.assertEqual("", GiteaForge(_client()).read_issue(17).body)
def test_read_comments_maps_user_login(self):
raw = [
{"id": 1, "user": {"login": "alice"}, "body": "hi"},
{"id": 2, "user": {"login": "bob"}, "body": "yo"},
]
with patch(_URLOPEN, return_value=_resp(raw)):
comments = GiteaForge(_client()).read_comments(17)
self.assertEqual(["alice", "bob"], [c.user for c in comments])
self.assertEqual([1, 2], [c.id for c in comments])
class TestForgeWrites(unittest.TestCase):
def test_post_comment_payload_and_url(self):
with patch(_URLOPEN, return_value=_resp(None, 201)) as m:
GiteaForge(_client()).post_comment(17, "done ✓")
req = m.call_args.args[0]
self.assertEqual("POST", req.method)
self.assertIn("/repos/didericis/bot-bottle/issues/17/comments", req.full_url)
self.assertEqual("done ✓", json.loads(req.data)["body"])
def test_update_description_patches_issue(self):
with patch(_URLOPEN, return_value=_resp(None, 200)) as m:
GiteaForge(_client()).update_description(17, "edited")
req = m.call_args.args[0]
self.assertEqual("PATCH", req.method)
self.assertTrue(req.full_url.endswith("/issues/17"))
self.assertEqual("edited", json.loads(req.data)["body"])
def test_auth_header_sent(self):
with patch(_URLOPEN, return_value=_resp(None, 201)) as m:
GiteaForge(_client()).post_comment(17, "x")
self.assertEqual("token test-token",
m.call_args.args[0].headers["Authorization"])
class TestPRHelpers(unittest.TestCase):
def test_get_pr_for_issue_returns_number_when_issue_is_pr(self):
raw = {"number": 18, "pull_request": {"merged": False}}
with patch(_URLOPEN, return_value=_resp(raw)):
self.assertEqual(18, GiteaForge(_client()).get_pr_for_issue(18))
def test_get_pr_for_issue_none_for_plain_issue(self):
raw = {"number": 17, "pull_request": None}
with patch(_URLOPEN, return_value=_resp(raw)):
self.assertIsNone(GiteaForge(_client()).get_pr_for_issue(17))
def test_is_pr_open_true_when_state_open(self):
with patch(_URLOPEN, return_value=_resp({"state": "open"})):
self.assertTrue(GiteaForge(_client()).is_pr_open(18))
def test_is_pr_open_false_when_closed(self):
with patch(_URLOPEN, return_value=_resp({"state": "closed"})):
self.assertFalse(GiteaForge(_client()).is_pr_open(18))
def test_read_pr_maps_fields_including_merged(self):
raw = {"number": 18, "title": "Fix", "body": "patch",
"state": "closed", "merged": True}
with patch(_URLOPEN, return_value=_resp(raw)) as m:
pr = GiteaForge(_client()).read_pr(18)
self.assertEqual((18, "Fix", "patch", "closed", True),
(pr.number, pr.title, pr.body, pr.state, pr.merged))
self.assertIn("/repos/didericis/bot-bottle/pulls/18",
m.call_args.args[0].full_url)
def test_read_pr_merged_defaults_false(self):
with patch(_URLOPEN, return_value=_resp({"number": 18, "state": "open"})):
self.assertFalse(GiteaForge(_client()).read_pr(18).merged)
if __name__ == "__main__":
unittest.main()
@@ -1,99 +0,0 @@
"""Unit: SQLite forge state store (PRD forge-native-integration)."""
from __future__ import annotations
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from bot_bottle.contrib.gitea.forge_state import (
STATUS_FROZEN,
STATUS_RUNNING,
ForgeState,
SqliteForgeStateStore,
)
def _state(**over: object) -> ForgeState:
base = ForgeState(
owner="didericis",
repo="bot-bottle",
issue_number=17,
slug="implementer-abc12",
agent_name="implementer",
bottle_names=["claude"],
backend_name="docker",
agent_git_user="didericis-claude",
pr_number=42,
status=STATUS_FROZEN,
last_checkin_at="2026-06-29T12:04:12-04:00",
)
return replace(base, **over)
class ForgeStateStoreTest(unittest.TestCase):
def setUp(self) -> None:
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
self.store = SqliteForgeStateStore(tmp / "sub" / "bot-bottle.db")
def test_round_trip(self):
self.store.upsert(_state())
self.assertEqual(_state(), self.store.get("didericis", "bot-bottle", 17))
def test_missing_returns_none(self):
self.assertIsNone(self.store.get("nobody", "nope", 1))
def test_creates_db_parent_dirs(self):
# setUp pointed at a non-existent 'sub/' dir; init must create it.
self.assertIsNone(self.store.get("x", "y", 1)) # no raise
def test_upsert_replaces(self):
self.store.upsert(_state(status=STATUS_RUNNING))
self.store.upsert(_state(status=STATUS_FROZEN))
got = self.store.get("didericis", "bot-bottle", 17)
assert got is not None
self.assertEqual(STATUS_FROZEN, got.status)
# Still one row, not two.
self.assertEqual(1, len(self.store.all()))
def test_delete_is_idempotent(self):
self.store.upsert(_state())
self.store.delete("didericis", "bot-bottle", 17)
self.store.delete("didericis", "bot-bottle", 17) # no raise
self.assertIsNone(self.store.get("didericis", "bot-bottle", 17))
def test_all_lists_across_repos_sorted(self):
self.store.upsert(_state(issue_number=18, slug="other"))
self.store.upsert(_state(issue_number=17))
self.store.upsert(_state(owner="acme", repo="widget", issue_number=3))
states = self.store.all()
self.assertEqual(3, len(states))
self.assertEqual(
[("acme", 3), ("didericis", 17), ("didericis", 18)],
[(s.owner, s.issue_number) for s in states],
)
def test_all_empty(self):
self.assertEqual([], self.store.all())
def test_bottle_names_list_preserved(self):
self.store.upsert(_state(bottle_names=["claude", "dev"]))
got = self.store.get("didericis", "bot-bottle", 17)
assert got is not None
self.assertEqual(["claude", "dev"], got.bottle_names)
def test_pr_number_nullable(self):
self.store.upsert(_state(pr_number=None))
got = self.store.get("didericis", "bot-bottle", 17)
assert got is not None
self.assertIsNone(got.pr_number)
def test_persists_across_store_instances(self):
self.store.upsert(_state())
reopened = SqliteForgeStateStore(self.store._db_path) # pylint: disable=protected-access
self.assertEqual(_state(), reopened.get("didericis", "bot-bottle", 17))
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -19,7 +19,7 @@ from bot_bottle.egress import (
egress_sidecar_env_entries,
egress_token_env_map,
)
from bot_bottle.log import Die
from bot_bottle.errors import MissingEnvVarError
from bot_bottle.manifest import ManifestIndex
from bot_bottle.yaml_subset import parse_yaml_subset
@@ -499,14 +499,14 @@ class TestResolveTokenValues(unittest.TestCase):
self.assertEqual({"EGRESS_TOKEN_0": "the-value"}, out)
def test_missing_token_ref_dies(self):
with self.assertRaises(Die):
with self.assertRaises(MissingEnvVarError):
egress_resolve_token_values(
{"EGRESS_TOKEN_0": "GH_PAT"},
{},
)
def test_empty_token_ref_dies(self):
with self.assertRaises(Die):
with self.assertRaises(MissingEnvVarError):
egress_resolve_token_values(
{"EGRESS_TOKEN_0": "GH_PAT"},
{"GH_PAT": ""},
@@ -47,7 +47,6 @@ def _addon() -> EgressAddon:
a: EgressAddon = EgressAddon.__new__(EgressAddon)
a.config = Config(routes=(), log=LOG_FULL)
a.safe_tokens = set()
a._supervise_queue_dir = ""
a._supervise_slug = ""
a._token_allow_timeout = 300.0
return a
+3 -6
View File
@@ -212,7 +212,6 @@ def _addon(config: Config) -> EgressAddon:
a: EgressAddon = EgressAddon.__new__(EgressAddon)
a.config = config
a.safe_tokens = set()
a._supervise_queue_dir = ""
a._supervise_slug = ""
a._token_allow_timeout = 300.0
a.routes_path = "/nonexistent/routes.yaml"
@@ -386,10 +385,10 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
def _sha256_hex(_payload: Any) -> str:
return "hash"
def _noop(_a: Any, _b: Any) -> None:
def _noop(*_args: Any) -> None:
return None
def _read_response(_qd: Any, _pid: Any) -> Any:
def _read_response(_slug: Any, _pid: Any) -> Any:
if response_status is None:
raise OSError("not written yet") # forces poll -> timeout
return types.SimpleNamespace(status=response_status)
@@ -409,7 +408,6 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
class TestSuperviseBranch(unittest.TestCase):
def _supervised_addon(self) -> EgressAddon:
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
addon._supervise_queue_dir = "/tmp/egress-queue"
addon._supervise_slug = "test-bottle"
addon._token_allow_timeout = 0.05
return addon
@@ -632,14 +630,13 @@ class TestRedactSurfaces(unittest.TestCase):
class TestSuperviseWriteFailure(unittest.TestCase):
def test_write_proposal_oserror_blocks(self) -> None:
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
addon._supervise_queue_dir = "/tmp/egress-queue"
addon._supervise_slug = "test-bottle"
addon._token_allow_timeout = 0.05
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
fake = _fake_sv("approved")
def _raise(_qd: Any, _p: Any) -> None:
def _raise(_p: Any) -> None:
raise OSError("disk full")
fake.write_proposal = _raise
+37 -3
View File
@@ -14,10 +14,12 @@ from bot_bottle.git_gate import (
git_gate_render_access_hook,
git_gate_render_entrypoint,
git_gate_render_hook,
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
_resolve_identity_file,
git_gate_upstreams_for_bottle,
)
from bot_bottle.errors import MissingEnvVarError
from bot_bottle.manifest import ManifestIndex
from tests.fixtures import fixture_minimal, fixture_with_git
@@ -209,12 +211,23 @@ class TestHookRender(unittest.TestCase):
# the suppressed findings for human approval.
self.assertIn("--ignore-gitleaks-allow", hook)
self.assertIn("--report-format=json", hook)
self.assertIn('"tool": "gitleaks-allow"', hook)
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
self.assertIn("_sv.write_proposal", hook)
self.assertIn("_sv.read_response", hook)
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
self.assertIn("supervisor approved # gitleaks:allow", hook)
self.assertIn("supervisor rejected # gitleaks:allow", hook)
def test_inline_gitleaks_allow_python_imports_work_in_sidecar_layout(self):
hook = git_gate_render_hook()
# The sidecar image copies supervise.py flat under /app, while
# host-side tests import it through the bot_bottle package.
# Hooks execute from the bare repo directory, so the embedded
# Python must include /app and support both import layouts.
self.assertIn('PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}"', hook)
self.assertIn("import supervise as _sv", hook)
self.assertIn("from bot_bottle import supervise as _sv", hook)
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
hook = git_gate_render_hook()
self.assertIn(
@@ -371,6 +384,27 @@ class TestDynamicKeyProvisioning(unittest.TestCase):
self.assertEqual("/tmp/provisioned-key", _resolve_identity_file(entry, "demo", self.stage))
mock_provision.assert_called_once()
def test_prepare_defers_gitea_key_provisioning(self):
bottle = self._gitea_manifest().bottles["dev"]
with patch("bot_bottle.git_gate_provision._provision_dynamic_key") as mock_provision:
plan = _StubGate().prepare(bottle, "demo", self.stage)
mock_provision.assert_not_called()
self.assertEqual("", plan.upstreams[0].identity_file)
def test_launch_time_helper_provisions_gitea_keys(self):
bottle = self._gitea_manifest().bottles["dev"]
plan = _StubGate().prepare(bottle, "demo", self.stage)
with patch(
"bot_bottle.git_gate_provision._provision_dynamic_key",
return_value="/tmp/provisioned-key",
) as mock_provision:
updated = provision_git_gate_dynamic_keys(bottle, plan, self.stage)
mock_provision.assert_called_once_with(bottle.git[0], "demo", self.stage)
self.assertEqual("/tmp/provisioned-key", updated.upstreams[0].identity_file)
def test_revoke_skips_non_gitea_and_missing_id_file(self):
revoke_git_gate_provisioned_keys(fixture_with_git().bottles["dev"], self.stage)
@@ -388,7 +422,7 @@ class TestDynamicKeyProvisioning(unittest.TestCase):
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:
with patch.dict("os.environ", {}, clear=True), self.assertRaises(MissingEnvVarError) as cm:
revoke_git_gate_provisioned_keys(bottle, self.stage)
self.assertIn("env var is not set", str(cm.exception))
+3 -2
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from typing import Any, cast
from unittest.mock import patch
from bot_bottle.errors import MissingEnvVarError
from bot_bottle.git_gate import (
_gitconfig_validate_value,
_provision_dynamic_key,
@@ -120,7 +121,7 @@ class TestProvisionDynamicKey(unittest.TestCase):
patch.dict("os.environ", {}, clear=False):
import os
os.environ.pop("GITEA_TOK", None)
with self.assertRaises(RuntimeError):
with self.assertRaises(MissingEnvVarError):
_provision_dynamic_key(_gitea_entry(), "s", Path(d))
@@ -166,7 +167,7 @@ class TestRevokeProvisionedKeys(unittest.TestCase):
import os
os.environ.pop("GITEA_TOK", None)
(Path(d) / "repo-deploy-key-id").write_text("kid123")
with self.assertRaises(RuntimeError):
with self.assertRaises(MissingEnvVarError):
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
+13
View File
@@ -30,6 +30,19 @@ class TestMacosContainerBottle(unittest.TestCase):
argv,
)
def test_agent_argv_accepts_absolute_provider_command(self):
command = "/home/node/.codex/packages/standalone/current/bin/codex"
bottle = MacosContainerBottle(
"bot-bottle-dev-abc",
lambda: None,
None,
agent_command=command,
)
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv(["run"])
self.assertIn(command, argv)
self.assertNotIn("codex", argv)
def test_agent_argv_includes_workdir(self):
bottle = MacosContainerBottle(
"bot-bottle-dev-abc",
+4 -2
View File
@@ -71,7 +71,9 @@ def _plan(
else:
git_gate_plan = SimpleNamespace(upstreams=())
supervise_plan = (
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
SimpleNamespace(
db_path=Path("/state/bot-bottle.db"),
)
if supervise else None
)
agent_provision = SimpleNamespace(
@@ -137,7 +139,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
argv,
)
self.assertIn(
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
"type=bind,source=/state,target=/run/supervise",
argv,
)
+26 -1
View File
@@ -9,7 +9,11 @@ from __future__ import annotations
import unittest
from bot_bottle.manifest import ManifestBottle, ManifestIndex
from unittest.mock import patch
from bot_bottle.env import resolve_env
from bot_bottle.errors import MissingEnvVarError
from bot_bottle.manifest import Manifest, ManifestBottle, ManifestIndex
from bot_bottle.manifest_agent import (
ManifestAgent,
ManifestAgentProvider,
@@ -238,5 +242,26 @@ class TestEagerIndexLookups(unittest.TestCase):
self.assertIsNone(m.git_identity_summary())
class TestResolveEnv(unittest.TestCase):
"""resolve_env raises MissingEnvVarError when an interpolated entry's
host var is unset."""
def _manifest(self, env_dict: dict[str, str]) -> Manifest:
idx = ManifestIndex.from_json_obj({
"bottles": {"dev": {"env": env_dict}},
"agents": {"demo": {"bottle": "dev"}},
})
return idx.load_for_agent("demo")
def test_missing_interpolated_host_var_raises(self):
manifest = self._manifest({"API_KEY": "${HOST_API_KEY}"})
with patch.dict("os.environ", {}, clear=False):
import os
os.environ.pop("HOST_API_KEY", None)
with self.assertRaises(MissingEnvVarError) as cm:
resolve_env(manifest)
self.assertIn("HOST_API_KEY", str(cm.exception))
if __name__ == "__main__":
unittest.main()
+15 -2
View File
@@ -79,6 +79,11 @@ class TestClaudeArgvWrapped(unittest.TestCase):
"--",
"runuser", "-u", "node", "--",
"env", "HOME=/home/node", "USER=node",
(
"PATH=/home/node/.local/bin:"
"/home/node/.codex/packages/standalone/current/bin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
"claude",
],
argv,
@@ -127,16 +132,24 @@ class TestClaudeArgvWrapped(unittest.TestCase):
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
self.assertIn("NO_PROXY=localhost", argv)
def test_guest_env_path_overrides_default_path(self):
argv = _unwrap(_bottle(None, PATH="/custom/bin").agent_argv([]))
self.assertIn("PATH=/custom/bin", argv)
self.assertFalse(any(
item.startswith("PATH=/home/node/.local/bin")
for item in argv
))
def test_runuser_switch_precedes_claude(self):
# The dashboard's `_build_resume_argv_with_fallback` finds
# the `claude` token to split exec-framing from the claude
# tail. `runuser -u node --` must sit on the prefix side so
# the shell wrap inherits the UID switch.
argv = _bottle().agent_argv([])
agent_idx = argv.index("claude")
runuser_idx = argv.index("runuser")
self.assertEqual(
["runuser", "-u", "node", "--", "env"],
argv[agent_idx - 7:agent_idx - 2],
argv[runuser_idx:runuser_idx + 5],
)
def test_pi_provider_appends_system_prompt_without_print_mode(self):
+5 -1
View File
@@ -94,6 +94,10 @@ class TestEnsureSmolmachine(unittest.TestCase):
calls.append(name)
return _f
def save_and_record(image_ref: str, path: str) -> None:
Path(path).touch()
calls.append("save")
with patch.object(
_launch_mod.docker_mod, "build_image",
side_effect=record("build"),
@@ -102,7 +106,7 @@ class TestEnsureSmolmachine(unittest.TestCase):
return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object(
_launch_mod.docker_mod, "save",
side_effect=record("save"),
side_effect=save_and_record,
) as save, patch.object(
_launch_mod, "ephemeral_registry",
return_value=_Reg(),
+117 -14
View File
@@ -8,6 +8,7 @@ inspecting running bundle containers' port bindings."""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import tempfile
@@ -112,9 +113,16 @@ class TestEnsurePool(unittest.TestCase):
class TestAllocate(unittest.TestCase):
def test_returns_loopback_on_linux(self):
with patch.object(loopback_alias, "_is_macos", return_value=False):
self.assertEqual("127.0.0.1", loopback_alias.allocate("demo"))
def test_per_bottle_alias_on_linux(self):
# Linux gets the same per-bottle scoping as macOS (127/8 is
# 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):
# No bundles running -> first pool entry.
@@ -166,12 +174,25 @@ class TestAllocateLock(unittest.TestCase):
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
def test_no_lock_on_linux(self):
# Linux early-returns before touching the lock file.
def test_acquires_exclusive_lock_on_linux(self):
# Linux allocates per-bottle too, so it must take the same
# lock to serialise concurrent launches.
import fcntl as fcntl_mod
flock_calls: list[int] = []
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.fcntl, "flock") as flock:
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")
flock.assert_not_called()
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
def test_sequential_allocations_with_shared_lock_are_serialised(self):
# Two sequential calls share the same lock file. The second
@@ -241,10 +262,12 @@ class TestAliasInUseDetection(unittest.TestCase):
class TestForceAllowlist(unittest.TestCase):
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`,
so `force_allowlist` opens the state DB directly and sets
the row's `allowed_cidrs` field. Round-trip tests against a
real SQLite DB to lock down the BLOB encoding."""
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`, so
`force_allowlist` opens the state DB directly and sets the row's
`allowed_cidrs` field on both macOS and Linux. It is
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):
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
@@ -290,17 +313,67 @@ class TestForceAllowlist(unittest.TestCase):
self.assertEqual(4, cfg["cpus"])
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), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
# DB row should be untouched.
con = sqlite3.connect(str(self.db))
cfg = json.loads(con.execute(
"SELECT data FROM vms WHERE name='demo-vm'",
).fetchone()[0])
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: sqlite3.Connection, name: str) -> dict[str, object]:
# 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):
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"])
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__":
unittest.main()
+60 -3
View File
@@ -130,7 +130,7 @@ def _plan(
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
db_path=Path("/tmp/bot-bottle.db"),
)
return SmolmachinesBottlePlan(
spec=spec,
@@ -276,8 +276,8 @@ class TestProvisionCA(unittest.TestCase):
str(self.egress_ca),
AGENT_CA_PATH,
)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
self.assertEqual(2, bottle.exec.call_count)
script = bottle.exec.call_args_list[1].args[0]
self.assertIn("chmod 644", script)
self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
@@ -422,6 +422,14 @@ class TestBundleLaunchSpec(unittest.TestCase):
spec.environment,
)
def test_supervise_adds_daemon_volume_and_env(self):
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
plan = _plan(supervise=True)
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertIn("supervise", spec.daemons_csv)
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
self.assertIn(("/tmp/bot-bottle.db", DB_PATH_IN_CONTAINER, False), spec.volumes)
def test_canary_env_visible_to_smolvm_guest(self):
plan = _plan(canary=True)
with patch.object(
@@ -498,5 +506,54 @@ class TestProvisionGitUser(unittest.TestCase):
self.assertIn("bot@example.com", calls[0][0])
class TestProxyHost(unittest.TestCase):
"""_proxy_host returns the bridge gateway on Linux and the loopback
alias on other platforms."""
def test_linux_returns_bundle_gateway(self):
plan = _plan()
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
return_value="Linux"):
result = _launch._proxy_host(plan, "127.0.0.16")
self.assertEqual(plan.bundle_gateway, result)
def test_non_linux_returns_loopback(self):
plan = _plan()
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
return_value="Darwin"):
result = _launch._proxy_host(plan, "127.0.0.16")
self.assertEqual("127.0.0.16", result)
class TestDiscoverUrls(unittest.TestCase):
"""_discover_urls stamps git-gate host + supervise URL into the plan."""
def test_git_gate_host_set_when_upstreams_present(self):
plan = _plan()
plan = replace(
plan,
git_gate_plan=replace(
plan.git_gate_plan,
upstreams=(GitGateUpstream(
name="bot-bottle",
upstream_url="ssh://git@host/repo.git",
upstream_host="host",
upstream_port="22",
identity_file="/tmp/key",
known_host_key="",
),),
),
)
with patch.object(_launch._bundle, "bundle_host_port", return_value="9420"):
stamped = _launch._discover_urls(plan, "127.0.0.16")
self.assertEqual("127.0.0.16:9420", stamped.agent_git_gate_host)
def test_supervise_url_set_when_supervise_present(self):
plan = _plan(supervise=True)
with patch.object(_launch._bundle, "bundle_host_port", return_value="55556"):
stamped = _launch._discover_urls(plan, "127.0.0.16")
self.assertEqual("http://127.0.0.16:55556/", stamped.agent_supervise_url)
if __name__ == "__main__":
unittest.main()
+6 -5
View File
@@ -79,7 +79,7 @@ class TestArgvShapes(unittest.TestCase):
with self._patch_run() as m:
machine_create("agent-xyz")
self.assertEqual(
["smolvm", "machine", "create", "agent-xyz"],
["smolvm", "machine", "create", "--name", "agent-xyz"],
m.call_args.args[0],
)
@@ -103,7 +103,8 @@ class TestArgvShapes(unittest.TestCase):
self.assertIn("192.168.50.2/32", argv)
self.assertIn("-e", argv)
self.assertIn("HTTPS_PROXY=http://192.168.50.2:8888", argv)
self.assertEqual("agent-xyz", argv[-1])
self.assertIn("--name", argv)
self.assertIn("agent-xyz", argv)
def test_machine_create_omits_net_when_no_allow_cidrs(self):
with self._patch_run() as m:
@@ -127,13 +128,13 @@ class TestArgvShapes(unittest.TestCase):
m.call_args.args[0],
)
def test_machine_delete_uses_positional_name_and_force(self):
# delete NAME is positional; -f required so no interactive
def test_machine_delete_uses_name_flag_and_force(self):
# delete uses --name flag; -f required so no interactive
# confirmation blocks teardown.
with self._patch_run() as m:
machine_delete("agent-xyz")
self.assertEqual(
["smolvm", "machine", "delete", "-f", "agent-xyz"],
["smolvm", "machine", "delete", "--name", "agent-xyz", "-f"],
m.call_args.args[0],
)
+63
View File
@@ -56,9 +56,14 @@ class TestBundleSubnet(unittest.TestCase):
class TestPreflight(unittest.TestCase):
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(
"bot_bottle.backend.smolmachines.util.shutil.which",
return_value="/usr/local/bin/smolvm",
), patch(
"bot_bottle.backend.smolmachines.util.platform.system",
return_value="Darwin",
):
self.assertIsNone(smolmachines_preflight())
@@ -88,5 +93,63 @@ class TestPreflight(unittest.TestCase):
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: str, exists: bool, access: bool) -> None:
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__":
unittest.main()
+78 -40
View File
@@ -1,6 +1,5 @@
"""Unit: supervise queue + audit log + diff helpers (PRD 0013)."""
import json
import tempfile
import threading
import time
@@ -9,6 +8,9 @@ from datetime import datetime, timezone
from pathlib import Path
from bot_bottle import supervise
from bot_bottle import supervise_types as sv_types
from bot_bottle.audit_store import AuditStore
from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import (
AuditEntry,
Proposal,
@@ -19,7 +21,7 @@ from bot_bottle.supervise import (
TOOL_EGRESS_ALLOW,
TOOL_GITLEAKS_ALLOW,
archive_proposal,
audit_log_path,
host_db_path,
list_pending_proposals,
read_audit_entries,
read_proposal,
@@ -112,32 +114,52 @@ class TestResponseRoundtrip(unittest.TestCase):
class TestQueueIO(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.")
self.queue_dir = Path(self._tmp.name)
self._home_patch = self._patch_home(Path(self._tmp.name))
self.slug = "dev"
QueueStore("").migrate()
def tearDown(self):
self._home_patch()
self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
original_sv = supervise.bot_bottle_root
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
return restore
def test_write_and_read_proposal(self):
p = _proposal()
path = write_proposal(self.queue_dir, p)
path = write_proposal(p)
self.assertTrue(path.exists())
self.assertEqual(host_db_path(), path)
self.assertEqual(0o600, path.stat().st_mode & 0o777)
loaded = read_proposal(self.queue_dir, p.id)
loaded = read_proposal(self.slug, p.id)
self.assertEqual(p, loaded)
def test_list_pending_excludes_responded(self):
a = _proposal(justification="first")
b = _proposal(justification="second")
write_proposal(self.queue_dir, a)
write_proposal(self.queue_dir, b)
write_response(self.queue_dir, Response(
write_proposal(a)
write_proposal(b)
write_response(self.slug, Response(
proposal_id=a.id, status=STATUS_APPROVED, notes="",
))
pending = list_pending_proposals(self.queue_dir)
pending = list_pending_proposals(self.slug)
self.assertEqual([b.id], [p.id for p in pending])
def test_list_pending_returns_empty_for_missing_dir(self):
self.assertEqual([], list_pending_proposals(self.queue_dir / "nope"))
def test_list_pending_returns_empty_for_missing_slug(self):
self.assertEqual([], list_pending_proposals("nope"))
def test_list_pending_sorted_by_arrival(self):
# Fabricate two with explicit timestamps.
@@ -154,30 +176,30 @@ class TestQueueIO(unittest.TestCase):
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
)
# Write in reverse order.
write_proposal(self.queue_dir, b)
write_proposal(self.queue_dir, a)
ordered = list_pending_proposals(self.queue_dir)
write_proposal(b)
write_proposal(a)
ordered = list_pending_proposals(self.slug)
self.assertEqual([a.id, b.id], [p.id for p in ordered])
def test_write_and_read_response(self):
r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no")
write_response(self.queue_dir, r)
self.assertEqual(r, read_response(self.queue_dir, "xyz"))
write_response(self.slug, r)
self.assertEqual(r, read_response(self.slug, "xyz"))
def test_wait_for_response_returns_when_file_appears(self):
p = _proposal()
write_proposal(self.queue_dir, p)
write_proposal(p)
def write_after_delay():
time.sleep(0.05)
write_response(self.queue_dir, Response(
write_response(self.slug, Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="ok",
))
t = threading.Thread(target=write_after_delay)
t.start()
try:
r = wait_for_response(self.queue_dir, p.id, poll_interval=0.01)
r = wait_for_response(self.slug, p.id, poll_interval=0.01)
finally:
t.join()
self.assertEqual(STATUS_APPROVED, r.status)
@@ -187,44 +209,51 @@ class TestQueueIO(unittest.TestCase):
deadline = time.monotonic() + 0.05
with self.assertRaises(TimeoutError):
wait_for_response(
self.queue_dir, "never",
self.slug, "never",
poll_interval=0.01, deadline=deadline,
)
def test_archive_proposal_moves_both_files(self):
def test_archive_proposal_hides_rows(self):
p = _proposal()
write_proposal(self.queue_dir, p)
write_response(self.queue_dir, Response(
write_proposal(p)
write_response(self.slug, Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="",
))
archive_proposal(self.queue_dir, p.id)
self.assertFalse((self.queue_dir / f"{p.id}.proposal.json").exists())
self.assertFalse((self.queue_dir / f"{p.id}.response.json").exists())
self.assertTrue((self.queue_dir / "processed" / f"{p.id}.proposal.json").exists())
self.assertTrue((self.queue_dir / "processed" / f"{p.id}.response.json").exists())
archive_proposal(self.slug, p.id)
self.assertEqual([], list_pending_proposals(self.slug))
with self.assertRaises(FileNotFoundError):
read_response(self.slug, p.id)
def test_archive_is_idempotent_on_missing_files(self):
# Should not raise.
archive_proposal(self.queue_dir, "nope")
archive_proposal(self.slug, "nope")
class TestAuditLog(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-audit.")
self._home_patch = self._patch_home(Path(self._tmp.name))
AuditStore().migrate()
def tearDown(self):
self._home_patch()
self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
original = supervise.bot_bottle_root
original_sv = supervise.bot_bottle_root
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
return lambda: setattr(supervise, "bot_bottle_root", original)
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
return restore
def test_write_then_read_single_entry(self):
e = AuditEntry(
@@ -237,6 +266,7 @@ class TestAuditLog(unittest.TestCase):
diff="--- before\n+++ after\n",
)
path = write_audit_entry(e)
self.assertEqual(host_db_path(), path)
self.assertEqual(0o600, path.stat().st_mode & 0o777)
loaded = read_audit_entries("cred-proxy", "dev")
self.assertEqual([e], loaded)
@@ -252,12 +282,13 @@ class TestAuditLog(unittest.TestCase):
justification="",
diff="",
))
path = audit_log_path("egress", "dev")
with path.open() as f:
lines = [line for line in f if line.strip()]
self.assertEqual(3, len(lines))
for line in lines:
self.assertTrue(json.loads(line)) # each line is valid JSON
entries = read_audit_entries("egress", "dev")
self.assertEqual(3, len(entries))
self.assertEqual(
["2026-05-25T12:00:00+00:00", "2026-05-25T12:00:01+00:00",
"2026-05-25T12:00:02+00:00"],
[entry.timestamp for entry in entries],
)
def test_separate_logs_per_component_slug(self):
write_audit_entry(AuditEntry(
@@ -369,17 +400,24 @@ class TestSupervisePrepare(unittest.TestCase):
self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
original = supervise.bot_bottle_root
original_sv = supervise.bot_bottle_root
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
return lambda: setattr(supervise, "bot_bottle_root", original)
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
return restore
def test_prepare_creates_queue(self):
plan = _StubSupervise().prepare("dev", self.stage_dir)
self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.db_path.is_file())
self.assertEqual("dev", plan.slug)
self.assertEqual("", plan.internal_network)
+29 -29
View File
@@ -12,7 +12,10 @@ from pathlib import Path
from unittest.mock import patch
from bot_bottle import supervise
from bot_bottle import supervise_types as sv_types
from bot_bottle.audit_store import AuditStore
from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import (
Proposal,
STATUS_APPROVED,
@@ -52,13 +55,22 @@ class _FakeHomeMixin:
def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.")
original = supervise.bot_bottle_root
original_sv = supervise.bot_bottle_root
original_svt = sv_types.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original)
sv_types.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
supervise.bot_bottle_root = original_sv # type: ignore[assignment]
sv_types.bot_bottle_root = original_svt # type: ignore[assignment]
self._restore_home = restore
QueueStore("").migrate()
AuditStore().migrate()
def _teardown_fake_home(self):
self._restore_home()
@@ -77,9 +89,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
def test_walks_all_slug_subdirs(self):
for slug in ("dev", "api"):
qdir = supervise.queue_dir_for_slug(slug)
qdir.mkdir(parents=True)
supervise.write_proposal(qdir, _proposal(slug=slug))
supervise.write_proposal(_proposal(slug=slug))
pending = supervise_cli.discover_pending()
self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending})
@@ -97,18 +107,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
)
for p in (late, early):
qdir = supervise.queue_dir_for_slug(p.bottle_slug)
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
supervise.write_proposal(p)
pending = supervise_cli.discover_pending()
self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending])
def test_excludes_already_responded(self):
p = _proposal()
qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True)
supervise.write_proposal(qdir, p)
supervise.write_response(qdir, supervise.Response(
supervise.write_proposal(p)
supervise.write_response("dev", supervise.Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="",
))
self.assertEqual([], supervise_cli.discover_pending())
@@ -123,10 +129,8 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
p = _proposal(tool=tool)
qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
supervise.write_proposal(p)
return supervise_cli.QueuedProposal(proposal=p)
def test_approve_writes_response(self):
qp = self._enqueue()
@@ -135,7 +139,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
):
supervise_cli.approve(qp)
resp = read_response(qp.queue_dir, qp.proposal.id)
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status)
self.assertIsNone(resp.final_file)
@@ -150,7 +154,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
final_file="routes:\n - host: edited.example.com\n",
notes="tweaked",
)
resp = read_response(qp.queue_dir, qp.proposal.id)
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_MODIFIED, resp.status)
self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file)
self.assertEqual("tweaked", resp.notes)
@@ -158,7 +162,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_reject_writes_rejection(self):
qp = self._enqueue()
supervise_cli.reject(qp, reason="nope")
resp = read_response(qp.queue_dir, qp.proposal.id)
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status)
self.assertEqual("nope", resp.notes)
@@ -181,36 +185,33 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_approve_gitleaks_allow_leaves_response_for_gate(self):
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
supervise_cli.approve(qp, notes="dummy fixture")
# Gate polls the queue dir for the response; TUI must not archive it.
resp = read_response(qp.queue_dir, qp.proposal.id)
# Gate polls the DB for the response; TUI must not archive it.
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status)
self.assertEqual("dummy fixture", resp.notes)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_tui_gitleaks_allow_requires_reason(self):
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
with patch.object(supervise_cli, "_prompt", return_value=""):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertEqual("approve aborted (empty reason)", status)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_tui_gitleaks_allow_writes_reason(self):
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertIn("approved gitleaks-allow", status)
resp = read_response(qp.queue_dir, qp.proposal.id)
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual("test fixture", resp.notes)
def test_approve_token_allow_leaves_response_for_egress(self):
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
supervise_cli.approve(qp, notes="false positive")
# The egress addon polls the queue dir for the response; the TUI must
# The egress addon polls the DB for the response; the TUI must
# not archive it (the addon archives after reading).
resp = read_response(qp.queue_dir, qp.proposal.id)
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status)
self.assertEqual("false positive", resp.notes)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_token_allow_writes_no_audit_log(self):
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
@@ -222,14 +223,13 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
with patch.object(supervise_cli, "_prompt", return_value=""):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertEqual("approve aborted (empty reason)", status)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_tui_token_allow_writes_reason(self):
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
with patch.object(supervise_cli, "_prompt", return_value="legit"):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertIn("approved egress-token-allow", status)
resp = read_response(qp.queue_dir, qp.proposal.id)
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual("legit", resp.notes)
def test_suffix_for_token_allow_is_txt(self):
+126 -54
View File
@@ -4,7 +4,6 @@ fallback paths."""
from __future__ import annotations
import os
import tempfile
import time
import unittest
@@ -12,14 +11,19 @@ from pathlib import Path
from unittest.mock import patch
from bot_bottle import supervise
from bot_bottle.audit_store import AuditStore
from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import (
AuditEntry,
Proposal,
STATUS_APPROVED,
TOOL_EGRESS_ALLOW,
list_pending_proposals,
read_audit_entries,
read_proposal,
read_response,
wait_for_response,
write_audit_entry,
)
@@ -37,58 +41,59 @@ class TestPathHelpers(unittest.TestCase):
def test_bot_bottle_root(self) -> None:
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
def test_queue_dir_for_slug(self) -> None:
self.assertIn("slug", str(supervise.queue_dir_for_slug("slug")))
def test_id_from_non_proposal_filename(self) -> None:
self.assertIsNone(supervise._id_from_proposal_filename(Path("x.response.json")))
class TestReadMalformed(unittest.TestCase):
def test_read_proposal_non_dict(self) -> None:
def test_read_proposal_missing_row(self) -> None:
with tempfile.TemporaryDirectory() as d:
(Path(d) / "p.proposal.json").write_text("[]")
with self.assertRaises(ValueError):
read_proposal(Path(d), "p")
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(FileNotFoundError):
read_proposal("slug", "p")
def test_read_response_non_dict(self) -> None:
def test_read_response_missing_row(self) -> None:
with tempfile.TemporaryDirectory() as d:
(Path(d) / "p.response.json").write_text("[]")
with self.assertRaises(ValueError):
read_response(Path(d), "p")
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(FileNotFoundError):
read_response("slug", "p")
def test_list_pending_skips_malformed(self) -> None:
def test_list_pending_reads_db_only(self) -> None:
with tempfile.TemporaryDirectory() as d:
qd = Path(d)
(qd / "bad.proposal.json").write_text("{ not json")
(qd / "arr.proposal.json").write_text("[]")
(qd / "incomplete.proposal.json").write_text("{}") # from_dict raises
supervise.write_proposal(qd, _proposal()) # one valid
pending = list_pending_proposals(qd)
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
supervise.write_proposal(_proposal())
pending = list_pending_proposals("slug")
self.assertEqual(1, len(pending))
self.assertEqual("slug", pending[0].bottle_slug)
def test_list_pending_skips_when_response_present(self) -> None:
with tempfile.TemporaryDirectory() as d:
qd = Path(d)
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
p = _proposal()
supervise.write_proposal(qd, p)
(qd / f"{p.id}.response.json").write_text("{}") # response exists -> skipped
self.assertEqual([], list_pending_proposals(qd))
supervise.write_proposal(p)
supervise.write_response("slug", supervise.Response(
proposal_id=p.id,
status=STATUS_APPROVED,
notes="",
))
self.assertEqual([], list_pending_proposals("slug"))
class TestWaitForResponse(unittest.TestCase):
def test_malformed_response_then_timeout(self) -> None:
def test_missing_response_times_out(self) -> None:
with tempfile.TemporaryDirectory() as d:
(Path(d) / "p.response.json").write_text("{ not json")
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(TimeoutError):
wait_for_response(Path(d), "p", deadline=time.monotonic())
wait_for_response("slug", "p", deadline=time.monotonic())
def test_incomplete_response_then_timeout(self) -> None:
def test_empty_db_response_does_not_count(self) -> None:
with tempfile.TemporaryDirectory() as d:
(Path(d) / "p.response.json").write_text("{}") # dict but from_dict raises
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(TimeoutError):
wait_for_response(Path(d), "p", deadline=time.monotonic())
wait_for_response("slug", "p", deadline=time.monotonic())
class TestReadAuditEntries(unittest.TestCase):
@@ -97,35 +102,102 @@ class TestReadAuditEntries(unittest.TestCase):
patch.dict("os.environ", {"HOME": home}):
self.assertEqual([], read_audit_entries("egress", "nope"))
def test_skips_malformed_lines(self) -> None:
def test_reads_entries_from_db(self) -> None:
with tempfile.TemporaryDirectory() as home, \
patch.dict("os.environ", {"HOME": home}):
path = supervise.audit_log_path("egress", "slug")
path.parent.mkdir(parents=True, exist_ok=True)
valid = (
'{"timestamp": "t", "bottle_slug": "slug", "component": "egress",'
' "operator_action": "approve", "operator_notes": "",'
' "justification": "", "diff": ""}'
)
path.write_text(
"\n" # blank line skipped
"{ not json\n" # JSONDecodeError skipped
"[]\n" # not a dict skipped
"{}\n" # missing fields -> ValueError skipped
+ valid + "\n"
)
AuditStore().migrate()
write_audit_entry(AuditEntry(
timestamp="t",
bottle_slug="slug",
component="egress",
operator_action="approve",
operator_notes="",
justification="",
diff="",
))
write_audit_entry(AuditEntry(
timestamp="t",
bottle_slug="other",
component="egress",
operator_action="reject",
operator_notes="",
justification="",
diff="",
))
entries = read_audit_entries("egress", "slug")
self.assertEqual(1, len(entries))
self.assertEqual("approve", entries[0].operator_action)
def test_legacy_audit_log_file_does_not_count(self) -> None:
with tempfile.TemporaryDirectory() as home, \
patch.dict("os.environ", {"HOME": home}):
path = supervise.audit_log_path("egress", "slug")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
'{"timestamp": "t", "bottle_slug": "slug", "component": "egress",'
' "operator_action": "approve", "operator_notes": "",'
' "justification": "", "diff": ""}\n'
)
entries = read_audit_entries("egress", "slug")
self.assertEqual([], entries)
class TestFlockFallback(unittest.TestCase):
def test_flock_on_closed_fd_is_swallowed(self) -> None:
# flock on a closed fd raises OSError(EBADF), which the helpers swallow.
fd = os.open(os.devnull, os.O_RDONLY)
os.close(fd)
supervise._try_flock(fd)
supervise._try_funlock(fd)
class TestStoreGuardBranches(unittest.TestCase):
"""Direct QueueStore / AuditStore construction and early-return guard branches."""
def test_queue_store_explicit_db_path(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
self.assertTrue(db.is_file())
self.assertEqual(db, store.db_path)
def test_queue_store_missing_db_list_pending_returns_empty(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
db.unlink()
self.assertEqual([], store.list_pending_proposals())
def test_queue_store_missing_db_list_all_returns_empty(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
db.unlink()
self.assertEqual([], store.list_all_pending_proposals())
def test_queue_store_missing_db_archive_is_noop(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
db.unlink()
store.archive_proposal("anything") # must not raise
def test_queue_store_chmod_oserror_is_swallowed(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
store.migrate() # must not raise
def test_audit_store_missing_db_read_returns_empty(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "a.db"
store = AuditStore(db_path=db)
store.migrate()
db.unlink()
self.assertEqual([], store.read_audit_entries("egress", "slug"))
def test_audit_store_chmod_oserror_is_swallowed(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "a.db"
store = AuditStore(db_path=db)
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
store.migrate() # must not raise
if __name__ == "__main__":
+38 -18
View File
@@ -17,8 +17,12 @@ from unittest.mock import patch
# bare name `supervise`.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bottle"))
import supervise as _sv # noqa: E402 # type: ignore
import queue_store as _qs # noqa: E402 # type: ignore
import audit_store as _as # noqa: E402 # type: ignore
import supervise_types as svt_flat # noqa: E402 # type: ignore
from bot_bottle import supervise_server # noqa: E402
from bot_bottle import supervise_types as svt_pkg # noqa: E402
from bot_bottle.supervise_server import (
ERR_INTERNAL,
ERR_INVALID_PARAMS,
@@ -112,7 +116,7 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
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"))
config = ServerConfig(bottle_slug="dev")
with self.assertRaises(_RpcClientError) as cm:
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
@@ -122,9 +126,9 @@ 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:
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
self.assertRaises(_RpcInternalError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
@@ -265,21 +269,43 @@ class TestHandleToolsList(unittest.TestCase):
class TestHandleToolsCall(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
self.queue_dir = Path(self._tmp.name)
self.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir)
self._home_patch = self._patch_home(Path(self._tmp.name))
self.config = ServerConfig(bottle_slug="dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
def tearDown(self):
self._home_patch()
self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
original_sv = _sv.bot_bottle_root
original_flat = svt_flat.bot_bottle_root
original_pkg = svt_pkg.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
_sv.bot_bottle_root = fake_root # type: ignore[assignment]
svt_flat.bot_bottle_root = fake_root # type: ignore[assignment]
svt_pkg.bot_bottle_root = fake_root # type: ignore[assignment]
def restore() -> None:
_sv.bot_bottle_root = original_sv # type: ignore[assignment]
svt_flat.bot_bottle_root = original_flat # type: ignore[assignment]
svt_pkg.bot_bottle_root = original_pkg # type: ignore[assignment]
return restore
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
"""Background thread: poll the queue for a fresh proposal, write a
matching response. Returns the thread so the test can join it."""
def runner():
for _ in range(200):
pending = _sv.list_pending_proposals(self.queue_dir)
pending = _sv.list_pending_proposals("dev")
if pending:
p = pending[0]
_sv.write_response(self.queue_dir, _sv.Response(
_sv.write_response("dev", _sv.Response(
proposal_id=p.id, status=status, notes=notes,
))
return
@@ -412,15 +438,11 @@ class TestHandleToolsCall(unittest.TestCase):
finally:
responder.join()
# No pending proposals left after archive.
self.assertEqual([], _sv.list_pending_proposals(self.queue_dir))
# Both files moved to processed/.
processed = list((self.queue_dir / "processed").glob("*.json"))
self.assertEqual(2, len(processed))
self.assertEqual([], _sv.list_pending_proposals("dev"))
def test_pending_response_times_out_without_archive(self):
config = ServerConfig(
bottle_slug="dev",
queue_dir=self.queue_dir,
response_timeout_seconds=0.05,
)
result = handle_tools_call(
@@ -438,8 +460,7 @@ class TestHandleToolsCall(unittest.TestCase):
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
self.assertIn("proposal remains queued", text)
self.assertEqual(1, len(_sv.list_pending_proposals(self.queue_dir)))
self.assertFalse((self.queue_dir / "processed").exists())
self.assertEqual(1, len(_sv.list_pending_proposals("dev")))
class TestHandleListEgressRoutes(unittest.TestCase):
@@ -461,7 +482,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
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")),
ServerConfig(bottle_slug="dev"),
)
self.assertFalse(result["isError"]) # type: ignore[index]
@@ -476,7 +497,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
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")),
ServerConfig(bottle_slug="dev"),
)
self.assertTrue(result["isError"]) # type: ignore[index]
@@ -544,7 +565,6 @@ class TestHttpEndToEnd(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.")
self.queue_dir = Path(self._tmp.name)
# Pick a random port by binding to :0 first.
import socket
s = socket.socket()
@@ -552,7 +572,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.port = s.getsockname()[1]
s.close()
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
self.server.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir)
self.server.config = ServerConfig(bottle_slug="dev")
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True,
)