Add bot_bottle/api.py with four public functions the orchestrator uses:
start_headless, resume_headless, freeze, and destroy. These let a
ProgrammaticBottleRunner call directly into bot_bottle instead of
shelling out to the CLI; call sites in lifecycle.py stay unchanged.
Key changes:
- BottleSpec gains forge_env field for forge sidecar credentials
- _launch_bottle returns (slug, exit_code) instead of int so start_headless
can return the slug to callers
- All four API functions convert Die and non-zero exits to BottleError
- 27 new unit tests; existing tests updated for the new return type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses the five review comments on PR #318:
- Split PullRequest from Issue and add a dedicated read_pr method on
Forge/ScopedForge/GiteaForge (a PR carries merge state an issue does
not); is_pr_open now derives from read_pr.
- Replace the JSON-file forge state with a thin swappable CRUD interface
(ForgeStateStore) backed by SQLite (SqliteForgeStateStore) at
~/.bot-bottle/bot-bottle.db.
- Remove the provenance footer (provenance.py + its test): a mutable,
unsigned PR comment is not an audit record.
- Reword the PRD: provenance is exposed via an API, not surfaced in the
PR; document the Issue/PullRequest split and the SQLite store.
pyright clean (whole repo), pylint 10/10, 38 forge/resume unit tests pass;
no remaining refs to the removed provenance module or old JSON state API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
CI runs `pyright .` over the whole repo including tests; the earlier
run only checked the source paths. The test helpers used `**over`
dict-splat into typed constructors, which pyright strict rejects.
- forge_state: build a typed ForgeState base and dataclasses.replace(**over)
- provenance: explicit typed keyword params instead of a **over dict
- resume: _launch_kwargs returns dict[str, Any] (copy call_args.kwargs)
- forge_base: assert PermissionError in __mro__ (avoids always-true issubclass)
- client: annotate _resp body param; type: ignore the mock __enter__ lambda
pyright . now 0 errors; 47 tests still pass; pylint 9.97/10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
Implements the bot-bottle side of the forge-native PRD that is
self-contained in this repo (the forge sidecar and orchestrate command
belong to the separate bot-bottle-orchestrator, a PRD non-goal):
- contrib/forge/base.py: Forge ABC + ScopedForge enforcing the
read-anywhere / write-scoped model (writes rejected outside the
assigned issue/PRs via ForgeScopeError).
- contrib/gitea/client.py: GiteaClient (stdlib-only HTTP, mirrors the
deploy-key provisioner) + GiteaForge. Token held by the caller (the
sidecar), not injected by cred-proxy.
- contrib/gitea/forge_state.py: ForgeState dataclass + atomic
read/write/delete/all under ~/.bot-bottle/forge/<owner>/<repo>/.
- contrib/gitea/provenance.py: build_provenance_footer — collapsed
markdown audit footer; watchdog/gitleaks/egress rendering.
- cli/resume.py: `resume --headless --prompt` reusing the shipped
assume_yes + headless_prompt launch core (the new half of chunk 1).
47 new unit tests; pylint 9.98/10, pyright clean. Forge sidecar (chunk
4), orchestrate command (chunk 6), and forge_env plumbing are deferred:
their only consumer is the separate orchestrator and they are untestable
in isolation here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
#315 already merged `start --headless` (assume_yes on _launch_bottle +
AgentProvider.headless_prompt). The PRD's proposed start_headless /
attach_agent_headless helpers were redundant with it, and the latter
diverged by hand-rolling --no-interactive/-p instead of using the
headless_prompt provider abstraction. Drop them.
Scope the remaining headless work to what's actually new: a forge_env
hook threaded into the existing _launch_bottle core, and a `resume
--headless` path (resume has no non-interactive entry point today).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
Flip the forge-native-integration PRD from option 2 (agent calls the
Gitea API directly via cred-proxy; done signal parsed from comments) to
option 3 per issue #317 comment 2715: a forge sidecar backed by a Forge
abstract class.
- signal_done(status, summary) replaces comment-parsing as the done signal
- semantic audit trail from the sidecar feeds provenance directly
- read-anywhere / write-scoped enforcement, tighter than repo-wide API keys
- forge-agnostic agent prompts and sidecar protocol
- DeployKeyProvisioner subsumption deferred; share the HTTP client only
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
- Broaden scope from "Claude Code in Docker" to "AI coding agents in isolated sandboxes"
- Add SuperHQ (superhq.ai, v0.4.4) as a new adjacent-competitor entry: macOS-only
microVM desktop app, overlaps on isolation/credential-proxy/multi-provider but has
no manifest layer and no audit logging
- Note SuperHQ's user-voiced audit gap (Brian Cheong, Dunialabs.io) and that
bot-bottle already covers it
- Update differentiation list to reflect three backends (Docker, Apple container,
smolmachines) and three built-in providers (Claude Code, Codex, Pi) plus plugin system
- Add in-flight directions for forge-native dispatch (#317) and paid web control plane
(#327) with honest framing: lifecycle concept is not novel vs. cloud services (Devin,
Copilot Workspace); differentiation is self-hosted + manifest-driven + stronger isolation
CI runs as root, so chmod 0o000 doesn't prevent reads. Both
test_skips_unreadable_file (TestFindRepoBottleFile) and
test_returns_false_when_file_unreadable_on_write now mock
Path.read_text to raise OSError directly.
DX improvement: find_repo_bottle_file() is called before the save
prompt so the user sees exactly which file will be updated:
Save host_key for 'myrepo' to /home/.../bottles/dev.md? [y/N]
If no bottle file is found the prompt is skipped and a clear message
explains that the key is session-only.
Also:
- Use find_repo_bottle_file() inside find_and_update_bottle_file()
instead of duplicating the scan logic
- Rename all module-level helpers to public names (no underscore prefix)
since they are imported and tested externally
The regex-based _insert_host_key_in_frontmatter had a bug: child_indent
was overwritten on every line with indent > repo_indent (including
grandchildren), so host_key landed inside the key: block instead of at
the repo level.
Replace with a clean parse → mutate → re-serialize approach:
- Add serialize_yaml_subset() to yaml_subset.py (block-style, 2-space
indent, round-trips through parse_yaml_subset)
- Replace _insert_host_key_in_frontmatter + _update_frontmatter_in_file
with _add_host_key_to_frontmatter that parses the frontmatter dict,
sets fm["git-gate"]["repos"][name]["host_key"], and re-serializes
- _find_and_update_bottle_file simplified to read file → delegate to
_add_host_key_to_frontmatter → write if changed
Servers that don't offer ed25519 (ecdsa-only or rsa-only gitea
instances) would raise "ssh-keyscan returned no ed25519 key" because
`-t ed25519` produced no output.
Remove the `-t ed25519` restriction: ssh-keyscan now returns all
supported key types and the function picks the best available via
_KEY_TYPE_PREFERENCE (ed25519 > ecdsa > rsa > first).
Also adds four new fetch_host_key tests covering multi-type preference
logic and updates the stale "no ed25519 key" assertion.
- Remove unused `import dataclasses` and stray `die as real_die` import
- Replace lambda side_effect callbacks with list side_effect; lambdas
with untyped parameters trigger reportUnknownLambdaType in pyright
>=1.1.400 — side_effect=[...] is both cleaner and type-safe
- Bump pyright floor from >=1.1.300 to >=1.1.411 in requirements-dev.txt
so CI installs a version that matches what the lint job runs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a git-gate repo entry has no host_key configured, the prepare step
now either errors (headless) or fetches the key via ssh-keyscan, shows
it to the operator for confirmation, and optionally persists it to the
bottle config .md file on disk.
Closes#333
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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>
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
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
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>
Move _MIGRATIONS from module scope into AuditStore.__init__ and
QueueStore.__init__, treating table schema as a hidden implementation
detail of each store class.
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.
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>
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.
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
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).
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>