Compare commits

..

8 Commits

Author SHA1 Message Date
didericis-claude 7278ee1157 fix(claude): fall back to macOS Keychain for credentials
lint / lint (push) Failing after 2m10s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m6s
On macOS, Claude Code stores credentials in the Keychain under
service "Claude Code-credentials" rather than in a file. When
~/.claude/.credentials.json is absent, shell out to:
  security find-generic-password -s "Claude Code-credentials" -w
and parse the result as the same JSON schema.

~/.claude.json holds only profile/UI metadata (oauthAccount has
no token fields). expiresAt in the credentials is milliseconds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 21:46:53 +00:00
didericis-claude bdd352570b fix(claude): read credentials from ~/.claude/.credentials.json
lint / lint (push) Successful in 2m10s
test / unit (pull_request) Successful in 53s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m8s
The actual OAuth token is in ~/.claude/.credentials.json under
claudeAiOauth.accessToken, not in ~/.claude.json.
~/.claude.json holds only UI state and profile metadata (oauthAccount
has no token fields). expiresAt in the credentials file is milliseconds,
not seconds.

Discovered after testing against Claude Code 2.1.198.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 21:33:04 +00:00
didericis-claude f0d27863c2 feat(claude): add forward_host_credentials support
lint / lint (push) Successful in 2m19s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m14s
Reads the host's Claude OAuth session key from ~/.claude.json at launch
and forwards it only to the egress sidecar (never to the agent), placing
a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent env so Claude Code
starts without seeing the real credential.

Mirrors the existing Codex forward_host_credentials flow (PRD 0029).
Adds claude_auth.py to extract and validate the sessionKey, a
CLAUDE_HOST_CREDENTIAL_TOKEN_REF constant in egress.py, and updates
manifest_agent.py to allow the flag for both 'codex' and 'claude'
templates. Also adds a mutual-exclusion check that rejects setting
both auth_token and forward_host_credentials together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 21:14:37 +00:00
didericis-claude 71699b3ecd fix: resolve pylint/pyright issues in new test files
lint / lint (push) Successful in 2m7s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Successful in 1m4s
- test_contrib_gitea_client: remove unused Any import, fix _mock_response
  to use return_value instead of lambda (unknown lambda type), narrow
  HTTPError hdrs type, add type annotations to fake_urlopen helpers,
  suppress protected-access for _request tests
- test_bootstrap: annotate **kw as **kw: object, use dict literal,
  unpack server_address via index to avoid tuple type mismatch
- test_main: remove unused MagicMock import
- test_watchdog: guard store.get() result before accessing .status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 19:47:31 +00:00
didericis-claude 57290da1e8 test: add coverage for orchestrator + gitea client (diff gate 77% → 98%)
lint / lint (push) Failing after 2m5s
test / unit (pull_request) Successful in 53s
test / integration (pull_request) Successful in 24s
test / coverage (pull_request) Successful in 1m12s
Three new unit test modules:
- tests/unit/test_contrib_gitea_client.py — GiteaClient (urllib mocked)
  and GiteaForge delegation
- tests/unit/orchestrator/test_main.py — __main__ run/status commands
- tests/unit/orchestrator/test_bootstrap.py — _token, BotBottleStateStore,
  _to_forge_state/_to_record, make_forge, make_sidecar, build

Augments to existing suites:
- test_events: non-"created" comment action ignored
- test_lifecycle: _iso_now callable, untracked-issue comment ignored,
  untracked-PR closed ignored (covers _find_by_pr return-None path)
- test_runner: destroy command, _default_run via subprocess mock
- test_sidecar: _jsonable dataclass/list branches, OpLog.read on missing
  file, drain_done_events on corrupted file, socket _Handler invalid-JSON
  and empty-line paths, serve() with pre-existing socket path
- test_watchdog: _loop body covered by patching _TICK_SECS to 0.01s
- test_webhook: unknown GET path returns 404

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 19:35:30 +00:00
didericis-claude df1f0e8f70 docs: mark fold-orchestrator PRD as Active
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Failing after 1m7s
2026-07-01 17:18:38 +00:00
didericis-claude 314dc03b0d feat: fold bot-bottle-orchestrator into bot_bottle/orchestrator subpackage
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets
everything. Entry point is now `python -m bot_bottle.orchestrator run`.

- Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal
  imports were already relative, so no changes inside orchestrator modules)
- Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct
  relative imports from ..contrib.*
- Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped)
- Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only)
- Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore
- Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules)

Closes #321
2026-07-01 17:18:28 +00:00
didericis-claude 06025687ed docs: add PRD for folding orchestrator into bot-bottle subpackage 2026-07-01 17:14:43 +00:00
134 changed files with 4946 additions and 6008 deletions
+2 -8
View File
@@ -18,7 +18,7 @@
# /git-gate-entrypoint.sh docker-cp'd at start time # /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time # /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime # /git/* bare repos, populated at runtime
# /run/supervise/bot-bottle.db bind-mounted at run time # /run/supervise/queue/ bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
# #
# Exposed ports inside the container: # Exposed ports inside the container:
@@ -66,12 +66,6 @@ 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/egress_addon.py /app/egress_addon.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.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.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
@@ -87,7 +81,7 @@ RUN mkdir -p \
/etc/git-gate \ /etc/git-gate \
/git-gate/creds \ /git-gate/creds \
/git \ /git \
/run/supervise \ /run/supervise/queue \
/home/mitmproxy/.mitmproxy /home/mitmproxy/.mitmproxy
# Documentation only — the compose renderer publishes whichever # Documentation only — the compose renderer publishes whichever
+3 -19
View File
@@ -5,7 +5,7 @@
# bot-bottle # 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) [![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/) [![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) [![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. **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. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. - **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. Runs on macOS (Hypervisor.framework) and Linux (KVM, `/dev/kvm`). - **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. - **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
## Architecture ## Architecture
@@ -71,26 +71,10 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
## Quickstart ## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus `smolvm` (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`. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
### smolmachines on Linux
The smolmachines backend runs on Linux as well as macOS. On Linux, `smolvm`/libkrun use KVM, so the host needs:
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
- **`smolvm`** on `PATH`: `curl -sSL https://smolmachines.com/install.sh | sh`.
- **Docker** for the sidecar bundle and image build, same as macOS.
Per-bottle isolation works the same as macOS without any `ifconfig`/sudo step — all of `127.0.0.0/8` is already loopback on Linux, so each bottle's sidecar bundle is published on its own `127.0.0.<N>` and TSI's allowlist is scoped to that `/32`.
```sh
BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>
```
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. If you run bottles from a Gitea Actions runner, use a `host`-label runner so Docker, `smolvm`, and `/dev/kvm` are all reachable from the job. `smolvm` isn't in nixpkgs — install the release binary (pin the version) and put it on the runner's `PATH`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
-10
View File
@@ -1,11 +1 @@
"""bot-bottle: Python implementation of the agent container launcher.""" """bot-bottle: Python implementation of the agent container launcher."""
from .bottle import BottleError, destroy, freeze, resume_headless, start_headless
__all__ = [
"BottleError",
"destroy",
"freeze",
"resume_headless",
"start_headless",
]
+4 -4
View File
@@ -45,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
# forward_host_credentials is enabled. Pipelock must pass these through # forward_host_credentials is enabled. Pipelock must pass these through
# (no TLS MITM) or its header DLP blocks the injected JWT. # (no TLS MITM) or its header DLP blocks the injected JWT.
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
# Host that egress injects the host Claude bearer on when Claude
# forward_host_credentials is enabled.
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
PromptMode = Literal[ PromptMode = Literal[
"append_file", "append_file",
"read_prompt_file", "read_prompt_file",
@@ -227,10 +231,6 @@ class AgentProvider(ABC):
from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
from .log import die from .log import die
cert_host_path, label = select_ca_cert(plan.egress_plan) 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) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
r = bottle.exec( r = bottle.exec(
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates", f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
-14
View File
@@ -1,14 +0,0 @@
"""Compatibility wrapper for the public bottle runner API.
The implementation lives in :mod:`bot_bottle.bottle.runner`.
"""
from .bottle.runner import BottleError, destroy, freeze, resume_headless, start_headless
__all__ = [
"BottleError",
"destroy",
"freeze",
"resume_headless",
"start_headless",
]
-91
View File
@@ -1,91 +0,0 @@
"""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"]
+1 -16
View File
@@ -37,7 +37,7 @@ import shlex
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
@@ -75,14 +75,6 @@ class BottleSpec:
# Ordered bottle names selected at launch (issue #269). When non-empty # Ordered bottle names selected at launch (issue #269). When non-empty
# they are merged in order and replace the agent's `bottle:` field. # they are merged in order and replace the agent's `bottle:` field.
bottle_names: tuple[str, ...] = () bottle_names: tuple[str, ...] = ()
# True when launched via --headless (no TTY, no interactive prompts).
# The git-gate host-key preflight uses this to error rather than prompt.
headless: bool = False
# Forge sidecar env vars (PRD forge-native-integration, chunk 1).
# Passed by the orchestrator at launch time; the forge sidecar reads
# them to connect to Gitea. Empty for non-forge runs. The agent
# process itself does not receive these.
forge_env: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -308,13 +300,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
self._preflight() self._preflight()
from ..git_gate_host_key import preflight_host_keys
manifest = preflight_host_keys(
manifest,
headless=spec.headless,
home_md=spec.manifest.home_md,
)
manifest_bottle = manifest.bottle manifest_bottle = manifest.bottle
manifest_agent_provider = manifest_bottle.agent_provider manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template) agent_provider = get_provider(manifest_agent_provider.template)
+1 -1
View File
@@ -31,7 +31,7 @@ from ... import supervise as _supervise
from ...log import info, warn from ...log import info, warn
from . import util as docker_mod from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from ...bottle.state import bottle_state_dir, is_preserved from ...bottle_state import bottle_state_dir, is_preserved
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
+5 -4
View File
@@ -34,7 +34,7 @@ from ...egress import (
from ...git_gate import GIT_GATE_HOSTNAME from ...git_gate import GIT_GATE_HOSTNAME
from ...log import die, warn from ...log import die, warn
from ...supervise import ( from ...supervise import (
DB_PATH_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER,
SUPERVISE_HOSTNAME, SUPERVISE_HOSTNAME,
SUPERVISE_PORT, SUPERVISE_PORT,
) )
@@ -163,15 +163,16 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
if sp is not None: if sp is not None:
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
] ]
volumes.append({ volumes.append({
"type": "bind", "type": "bind",
"source": str(sp.db_path), "source": str(sp.queue_dir),
"target": DB_PATH_IN_CONTAINER, "target": QUEUE_DIR_IN_CONTAINER,
"read_only": False, "read_only": False,
}) })
internal_aliases = [EGRESS_HOSTNAME] internal_aliases = [EGRESS_HOSTNAME]
if gp.upstreams: if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME) internal_aliases.append(GIT_GATE_HOSTNAME)
+1 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import subprocess import subprocess
from .. import ActiveAgent from .. import ActiveAgent
from ...bottle.state import read_metadata from ...bottle_state import read_metadata
from .compose import compose_project_name, list_active_slugs from .compose import compose_project_name, list_active_slugs
+1 -1
View File
@@ -46,7 +46,7 @@ from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from ...bottle.state import ( from ...bottle_state import (
bottle_state_dir, bottle_state_dir,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
+1 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from ..bottle.state import egress_state_dir from ..bottle_state import egress_state_dir
from ..egress import EGRESS_ROUTES_FILENAME from ..egress import EGRESS_ROUTES_FILENAME
from ..egress_addon_core import LOG_OFF, load_config from ..egress_addon_core import LOG_OFF, load_config
+2 -2
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from . import ActiveAgent from . import ActiveAgent
from ..bottle.state import mark_preserved, write_committed_image from ..bottle_state import mark_preserved, write_committed_image
from ..log import die, info from ..log import die, info
@@ -66,7 +66,7 @@ class Freezer(ABC):
def commit_slug(self, slug: str) -> None: def commit_slug(self, slug: str) -> None:
"""Convenience entry for cmd_commit when only a slug is available.""" """Convenience entry for cmd_commit when only a slug is available."""
from ..bottle.state import read_metadata from ..bottle_state import read_metadata
metadata = read_metadata(slug) metadata = read_metadata(slug)
agent = ActiveAgent( agent = ActiveAgent(
backend_name=self.backend_name, backend_name=self.backend_name,
@@ -4,7 +4,7 @@ from __future__ import annotations
import subprocess import subprocess
from ...bottle.state import read_metadata from ...bottle_state import read_metadata
from .. import ActiveAgent from .. import ActiveAgent
_PREFIX = "bot-bottle-" _PREFIX = "bot-bottle-"
+4 -12
View File
@@ -17,7 +17,7 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...bottle.state import ( from ...bottle_state import (
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
read_committed_image, read_committed_image,
@@ -33,7 +33,7 @@ from ...git_gate import (
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import die, info, warn from ...log import die, info, warn
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
from ..docker.git_gate import ( from ..docker.git_gate import (
@@ -379,7 +379,7 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
] ]
return tuple(env) return tuple(env)
@@ -405,15 +405,7 @@ def _sidecar_mounts(
sp = plan.supervise_plan sp = plan.supervise_plan
if sp is not None: if sp is not None:
# `container run --mount type=bind` only accepts directory mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# 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) return tuple(mounts)
+1 -1
View File
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ..agent_provider import AgentProvisionPlan from ..agent_provider import AgentProvisionPlan
from ..bottle.state import ( from ..bottle_state import (
BottleMetadata, BottleMetadata,
agent_state_dir, agent_state_dir,
bottle_identity, bottle_identity,
-14
View File
@@ -47,24 +47,10 @@ _HOME_FOR = {
"root": "/root", "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]: def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]:
home = _HOME_FOR.get(user, f"/home/{user}") home = _HOME_FOR.get(user, f"/home/{user}")
out = [f"HOME={home}", f"USER={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(): for k, v in env.items():
out.append(f"{k}={v}") out.append(f"{k}={v}")
return out return out
+1 -1
View File
@@ -23,7 +23,7 @@ import json
import subprocess import subprocess
from .. import ActiveAgent from .. import ActiveAgent
from ...bottle.state import read_metadata from ...bottle_state import read_metadata
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
+1 -1
View File
@@ -18,7 +18,7 @@ from ..freeze import Freezer
from ..docker import util as docker_mod from ..docker import util as docker_mod
from .local_registry import crane_push_tarball, ephemeral_registry from .local_registry import crane_push_tarball, ephemeral_registry
from .smolvm import machine_cp, machine_exec, pack_create from .smolvm import machine_cp, machine_exec, pack_create
from ...bottle.state import bottle_state_dir from ...bottle_state import bottle_state_dir
from ...log import die, info from ...log import die, info
+42 -82
View File
@@ -17,7 +17,6 @@ from __future__ import annotations
import dataclasses import dataclasses
import os import os
import platform
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
@@ -28,7 +27,7 @@ from ...egress import (
egress_resolve_token_values, egress_resolve_token_values,
egress_sidecar_env_entries, egress_sidecar_env_entries,
) )
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.egress import ( from ..docker.egress import (
@@ -47,7 +46,7 @@ from ...git_gate import (
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...log import info, warn
from ...bottle.state import ( from ...bottle_state import (
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
read_committed_image, read_committed_image,
@@ -92,13 +91,12 @@ def launch(
try: try:
loopback_ip, network = _allocate_resources(plan, stack) loopback_ip, network = _allocate_resources(plan, stack)
plan = _mint_certs(plan) plan = _mint_certs(plan)
proxy_host = _proxy_host(plan, loopback_ip) plan = _start_bundle(plan, network, loopback_ip, stack)
plan = _start_bundle(plan, network, proxy_host, stack) plan = _discover_urls(plan, loopback_ip)
plan = _discover_urls(plan, proxy_host)
agent_from_path = _agent_from_path(plan) agent_from_path = _agent_from_path(plan)
_launch_vm(plan, agent_from_path, proxy_host, stack) _launch_vm(plan, agent_from_path, loopback_ip, stack)
_init_vm(plan) _init_vm(plan)
bottle = SmolmachinesBottle( bottle = SmolmachinesBottle(
@@ -146,12 +144,10 @@ def _allocate_resources(
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Reserve a loopback alias and create the per-bottle docker bridge. """Reserve a loopback alias and create the per-bottle docker bridge.
The per-bottle alias scopes TSI's allowlist to this bottle's macOS only routes 127.0.0.1 by default; the per-bottle alias
published ports so the agent can't reach other bottles' or host scopes TSI's allowlist to this bottle's published ports so the
services' ports on loopback. On macOS `ensure_pool` first agent can't reach other bottles' or host services' ports on
sudo-aliases the pool on `lo0`; on Linux that's a no-op since loopback. No-op on Linux."""
all of 127.0.0.0/8 is already loopback, but the per-bottle
allocation runs on both."""
_loopback.ensure_pool() _loopback.ensure_pool()
loopback_ip = _loopback.allocate(plan.slug) loopback_ip = _loopback.allocate(plan.slug)
network = _bundle.bundle_network_name(plan.slug) network = _bundle.bundle_network_name(plan.slug)
@@ -176,13 +172,13 @@ def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
def _start_bundle( def _start_bundle(
plan: SmolmachinesBottlePlan, plan: SmolmachinesBottlePlan,
network: str, network: str,
proxy_host: str, loopback_ip: str,
stack: ExitStack, stack: ExitStack,
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
"""Build the BundleLaunchSpec, resolve token env, start the """Build the BundleLaunchSpec, resolve token env, start the
sidecar bundle container, and register teardown.""" sidecar bundle container, and register teardown."""
plan = _provision_git_gate_keys(plan) plan = _provision_git_gate_keys(plan)
bundle_spec = _bundle_launch_spec(plan, network, proxy_host) bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
token_env = _resolve_token_env(plan, dict(os.environ)) token_env = _resolve_token_env(plan, dict(os.environ))
_bundle.ensure_bundle_image(bundle_spec.image) _bundle.ensure_bundle_image(bundle_spec.image)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env}) _bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
@@ -205,40 +201,39 @@ def _provision_git_gate_keys(
def _discover_urls( def _discover_urls(
plan: SmolmachinesBottlePlan, plan: SmolmachinesBottlePlan,
proxy_host: str, loopback_ip: str,
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
"""Discover host-side ports for published container ports and """Discover host-side ports for published container ports and
return the plan with URLs + guest_env stamped in. return the plan with URLs + guest_env stamped in.
`proxy_host` is the host IP that both TSI's allowlist and Docker container IPs (192.168.x.x in the daemon's bridge)
docker's port-forward bindings are keyed to. On macOS it is the aren't reachable from the smolvm guest on macOS — TSI uses
per-bottle loopback alias; on Linux it is the per-bottle bridge macOS networking, and macOS sees the daemon's bridge via the
gateway (see `_proxy_host`). The agent dials the published port published-port loopback forward only.
on this IP for all bundle-hosted services.
NO_PROXY includes `proxy_host` so supervise + git-gate URLs NO_PROXY includes the per-bottle loopback alias so the
bypass HTTPS_PROXY.""" supervise + git-gate URLs bypass HTTPS_PROXY."""
agent_facing_host_port = _bundle.bundle_host_port( agent_facing_host_port = _bundle.bundle_host_port(
plan.slug, _EGRESS_PORT, host_ip=proxy_host, plan.slug, _EGRESS_PORT, host_ip=loopback_ip,
) )
agent_proxy_url = f"http://{proxy_host}:{agent_facing_host_port}" agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
agent_git_gate_host = "" agent_git_gate_host = ""
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port( git_gate_host_port = _bundle.bundle_host_port(
plan.slug, _GIT_HTTP_PORT, host_ip=proxy_host, plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip,
) )
agent_git_gate_host = f"{proxy_host}:{git_gate_host_port}" agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_supervise_url = "" agent_supervise_url = ""
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port( supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, host_ip=proxy_host, plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
) )
agent_supervise_url = f"http://{proxy_host}:{supervise_host_port}/" agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1") existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
no_proxy = f"{existing_no_proxy},{proxy_host}" no_proxy = f"{existing_no_proxy},{loopback_ip}"
guest_env = { guest_env = {
**plan.guest_env, **plan.guest_env,
"HTTPS_PROXY": agent_proxy_url, "HTTPS_PROXY": agent_proxy_url,
@@ -268,32 +263,27 @@ def _discover_urls(
def _launch_vm( def _launch_vm(
plan: SmolmachinesBottlePlan, plan: SmolmachinesBottlePlan,
agent_from_path: Path, agent_from_path: Path,
proxy_host: str, loopback_ip: str,
stack: ExitStack, stack: ExitStack,
) -> None: ) -> None:
"""Create, patch, and start the smolvm VM; register teardown. """Create, patch, and start the smolvm VM; register teardown.
--allow-cidr is `proxy_host/32` — the per-bottle loopback alias --allow-cidr is the per-bottle loopback alias so the guest can
on macOS or the bridge gateway on Linux (see `_proxy_host`). This only reach this bottle's bundle ports. force_allowlist patches
ensures the guest can only reach bundle ports published on that IP, smolvm 0.8.0's silent-drop of --allow-cidr when combined with
not the container IP directly. force_allowlist confirms the --from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
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.""" and --smolfile mutually exclusive."""
tsi_cidr = f"{proxy_host}/32"
_smolvm.machine_create( _smolvm.machine_create(
plan.machine_name, plan.machine_name,
from_path=agent_from_path, from_path=agent_from_path,
allow_cidrs=[tsi_cidr], allow_cidrs=[f"{loopback_ip}/32"],
env=plan.guest_env, env=plan.guest_env,
) )
stack.callback(_smolvm.machine_delete, plan.machine_name) stack.callback(_smolvm.machine_delete, plan.machine_name)
# Confirm the booted VM's TSI allowlist will actually enforce the # Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
# /32 before start (smolvm 0.8.0 silently drops `--allow-cidr` # when combined with `--from`. Patch the persisted state DB
# with `--from`, so the persisted state DB is patched if needed). # before start so the booted VM's TSI actually enforces.
# Fails closed if enforcement can't be confirmed. _loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
_loopback.force_allowlist(plan.machine_name, [tsi_cidr])
_smolvm.machine_start(plan.machine_name) _smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name)
@@ -302,9 +292,7 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
"""Repair filesystem ownership and wait for exec channel readiness. """Repair filesystem ownership and wait for exec channel readiness.
Ownership repair: smolvm's pack process remaps files to the host Ownership repair: smolvm's pack process remaps files to the host
invoker's uid (e.g. 501 on macOS, 1000 on Linux). The chowns use invoker's uid (501 on macOS). /home/node must be node:node so
names not numbers so they're correct on either. /home/node must
be node:node so
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
mode 1777 so non-root processes can create per-uid scratch dirs. mode 1777 so non-root processes can create per-uid scratch dirs.
All folded into one sh -c to avoid back-to-back exec calls All folded into one sh -c to avoid back-to-back exec calls
@@ -327,29 +315,8 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
_smolvm.wait_exec_ready(plan.machine_name) _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( def _bundle_launch_spec(
plan: SmolmachinesBottlePlan, network: str, proxy_host: str, plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
) -> _bundle.BundleLaunchSpec: ) -> _bundle.BundleLaunchSpec:
"""Build a BundleLaunchSpec from the resolved inner Plans. """Build a BundleLaunchSpec from the resolved inner Plans.
@@ -402,15 +369,14 @@ def _bundle_launch_spec(
daemons.append("supervise") daemons.append("supervise")
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
] ]
volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False)) volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest — # Container ports the agent reaches from the smolvm guest —
# published on `proxy_host` so the TSI allowlist and the docker # published on host loopback so the guest can dial via TSI +
# port-forward bindings point at the same IP. Egress is always # macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
# the agent's HTTP/HTTPS proxy.
ports_to_publish: list[int] = [_EGRESS_PORT] ports_to_publish: list[int] = [_EGRESS_PORT]
if gp.upstreams: if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT) ports_to_publish.append(_GIT_HTTP_PORT)
@@ -427,7 +393,7 @@ def _bundle_launch_spec(
environment=tuple(env), environment=tuple(env),
volumes=tuple(volumes), volumes=tuple(volumes),
ports_to_publish=tuple(ports_to_publish), ports_to_publish=tuple(ports_to_publish),
publish_host_ip=proxy_host, publish_host_ip=loopback_ip,
) )
@@ -501,12 +467,6 @@ def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
return sidecar return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar" tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
docker_mod.save(image_ref, str(tarball)) 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: try:
with ephemeral_registry() as handle: with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}" push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
@@ -33,13 +33,10 @@ sudo-add the missing pool on first use per boot — the aliases
persist on `lo0` until reboot, so subsequent launches don't persist on `lo0` until reboot, so subsequent launches don't
prompt. prompt.
On Linux the whole `127.0.0.0/8` is already routed to `lo`, so Linux native daemons share the host's network namespace; the
docker can publish a bundle's ports directly on `127.0.0.<N>` whole `127.0.0.0/8` is reachable by default and aliases are
with no `ifconfig`/sudo step. `ensure_pool` is therefore a no-op unnecessary. The pool logic detects native-Linux and skips sudo
on Linux, but per-bottle alias *allocation* and the TSI allowlist entirely; the DB patch is also gated on macOS.
DB patch run on both platforms — the isolation property is
identical, it's just cheaper to set up on Linux. The state-DB
path differs per platform (see `_smolvm_db_path`).
Allocation is coordinated by inspecting running bundle Allocation is coordinated by inspecting running bundle
containers' published host IPs — each bottle's bundle owns the containers' published host IPs — each bottle's bundle owns the
@@ -50,7 +47,6 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os
import platform import platform
import re import re
import sqlite3 import sqlite3
@@ -61,34 +57,20 @@ from typing import Iterable
from ...log import die, info from ...log import die, info
def _smolvm_db_path() -> Path: # smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
"""smolvm's persistent VM state — a SQLite DB whose `vms` table # table holds one JSON BLOB per machine. The Linux path is
holds one JSON BLOB per machine. macOS stores it under # different, but smolmachines is macOS-only in v1 (PRD 0023) so
`Application Support`; Linux follows the XDG base-dir spec # we hard-code this. If the file moves under us we'll see a
(`$XDG_DATA_HOME`, default `~/.local/share`). # clear FileNotFoundError; not worth defensive cross-platform
# detection until the backend actually needs Linux.
NOTE: the Linux location is inferred from smolvm's documented _SMOLVM_DB_PATH = (
`~/.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() Path.home()
/ "Library" / "Library"
/ "Application Support" / "Application Support"
/ "smolvm" / "smolvm"
/ "server" / "server"
/ "smolvm.db" / "smolvm.db"
) )
xdg_data = os.environ.get("XDG_DATA_HOME")
base = Path(xdg_data) if xdg_data else Path.home() / ".local" / "share"
return base / "smolvm" / "server" / "smolvm.db"
# Resolved once at import: the host platform doesn't change within a
# process. Tests patch this attribute directly.
_SMOLVM_DB_PATH = _smolvm_db_path()
# Sixteen aliases by default. Tunable for hosts that want more # Sixteen aliases by default. Tunable for hosts that want more
@@ -149,64 +131,30 @@ def ensure_pool() -> None:
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None: def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
"""Ensure the machine's persisted TSI allowlist equals """Patch smolvm's persistent VM-state DB to set the machine's
`allowed_cidrs`, failing **closed** if that can't be confirmed. `allowed_cidrs` to the given list. Workaround for smolvm
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
Runs on both macOS and Linux. It exists because smolvm 0.8.0 Must run AFTER `smolvm machine create` (the row has to
silently drops `--allow-cidr` when combined with `--from`, so exist) and BEFORE `smolvm machine start` (smolvm reads the
the allowlist has to be written into smolvm's persistent state row on start; in-flight VMs don't pick up changes). Once
DB before `machine start`. Rather than assume the flag was smolvm honors the CLI flag upstream this whole function is
dropped, we read the persisted row and only patch when it redundant — flag-respecting create + remove this call from
doesn't already match — so a newer smolvm that honors the flag launch.
is left untouched.
Must run AFTER `smolvm machine create` (the row has to exist) No-op on non-macOS — the DB path differs and the Linux
and BEFORE `smolvm machine start` (smolvm reads the row on smolmachines code path isn't exercised in v1."""
start; in-flight VMs don't pick up changes). if not _is_macos():
return
Fail-closed: if the state DB is missing, the row is missing, or
the allowlist still doesn't match after patching, we `die()`
rather than boot a VM whose egress confinement we can't verify
— an unconfirmed allowlist is a sandbox-escape risk (the agent
VM could reach all of host loopback)."""
want = list(allowed_cidrs)
if not _SMOLVM_DB_PATH.is_file(): if not _SMOLVM_DB_PATH.is_file():
die( die(
f"smolvm state DB not found at {_SMOLVM_DB_PATH}; cannot " f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
f"confirm the TSI allowlist is enforced. Refusing to launch " f"smolvm 0.8.0 expected? `smolvm --version` to check."
f"(fail-closed). Check `smolvm --version` and the DB "
f"location for your platform."
) )
con = sqlite3.connect(str(_SMOLVM_DB_PATH)) con = sqlite3.connect(str(_SMOLVM_DB_PATH))
try: try:
cfg = _read_machine_cfg(con, machine_name) cur = con.cursor()
if cfg.get("allowed_cidrs") != want: row = cur.execute(
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,), "SELECT data FROM vms WHERE name = ?", (machine_name,),
).fetchone() ).fetchone()
if row is None: if row is None:
@@ -214,7 +162,18 @@ def _read_machine_cfg(con: sqlite3.Connection, machine_name: str) -> dict[str, o
f"smolvm DB has no row for machine {machine_name!r}" f"smolvm DB has no row for machine {machine_name!r}"
f"machine_create must run before force_allowlist." f"machine_create must run before force_allowlist."
) )
return json.loads(row[0]) 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()
def allocate(_slug: str) -> str: def allocate(_slug: str) -> str:
@@ -225,17 +184,16 @@ def allocate(_slug: str) -> str:
used (no on-disk reservation, allocation is purely used (no on-disk reservation, allocation is purely
docker-state-driven). docker-state-driven).
Runs on both platforms: the allocation logic (docker-state On non-macOS the whole `127.0.0.0/8` is loopback by default;
inspection + the file lock) is platform-independent. macOS `127.0.0.1` is fine to share and we skip the alias dance.
needs `ensure_pool` to have aliased the addresses on `lo0` This still returns a deterministic address so launch.py's
first; on Linux all of `127.0.0.0/8` is already loopback, so callers don't have to branch on platform.
docker can publish on the chosen `127.0.0.<N>` with no setup.
Per-bottle scoping (so the agent can't reach other bottles' or
host services' loopback ports) therefore holds on both.
An exclusive file lock serialises concurrent calls so two An exclusive file lock serialises concurrent calls so two
simultaneous launches don't read the same docker state and simultaneous launches don't read the same docker state and
claim the same alias.""" claim the same alias."""
if not _is_macos():
return "127.0.0.1"
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) _ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf: with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
fcntl.flock(lf, fcntl.LOCK_EX) fcntl.flock(lf, fcntl.LOCK_EX)
+15 -10
View File
@@ -116,9 +116,10 @@ def machine_create(
allow_cidrs: Sequence[str] = (), allow_cidrs: Sequence[str] = (),
env: Mapping[str, str] | None = None, env: Mapping[str, str] | None = None,
) -> None: ) -> None:
"""`smolvm machine create --name NAME [--image IMG | --from PATH] """`smolvm machine create NAME [--image IMG | --from PATH]
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is passed as [--allow-cidr CIDR ...] [-e K=V ...]`. NAME is positional
`--name` (smolvm 1.4.7+; earlier versions took it positionally). (the CLI's exception to the `--name` pattern other
subcommands use).
`image` (registry ref like `alpine:latest`) and `from_path` `image` (registry ref like `alpine:latest`) and `from_path`
(a `.smolmachine` artifact) are mutually exclusive — one or (a `.smolmachine` artifact) are mutually exclusive — one or
@@ -132,10 +133,12 @@ def machine_create(
result without the Smolfile complication. result without the Smolfile complication.
`--net` is sent explicitly when `allow_cidrs` is non-empty. `--net` is sent explicitly when `allow_cidrs` is non-empty.
`--allow-cidr` implies `--net` per the CLI help, but sending smolvm 0.8.0's docs say `--allow-cidr` implies `--net`, but
`--net` explicitly is harmless and ensures the guest has empirically the implication only fires when no `--from` is
network access even if that implication changes across versions.""" set — `--from PATH --allow-cidr X/32` silently produces a
args: list[str] = ["machine", "create", "--name", name] 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"]
if image is not None: if image is not None:
args += ["--image", image] args += ["--image", image]
if from_path is not None: if from_path is not None:
@@ -147,6 +150,7 @@ def machine_create(
if env: if env:
for k, v in env.items(): for k, v in env.items():
args += ["-e", f"{k}={v}"] args += ["-e", f"{k}={v}"]
args.append(name)
_smolvm(*args) _smolvm(*args)
@@ -178,9 +182,10 @@ def machine_stop(name: str) -> None:
def machine_delete(name: str) -> None: def machine_delete(name: str) -> None:
"""`smolvm machine delete --name NAME -f`. `-f` skips the """`smolvm machine delete -f NAME`. NAME is positional. `-f`
interactive confirmation — required for non-interactive teardown.""" skips the interactive confirmation — required for
_smolvm("machine", "delete", "--name", name, "-f") non-interactive teardown."""
_smolvm("machine", "delete", "-f", name)
def machine_exec( def machine_exec(
+7 -39
View File
@@ -5,28 +5,19 @@ unit-tested without importing the docker subprocess paths."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import os
import platform
import shutil import shutil
from ...log import die from ...log import die
# libkrun's Linux backend drives the guest through KVM, so the host
# must expose `/dev/kvm` and the invoking user must be able to open
# it. macOS uses Hypervisor.framework and needs no device node.
_KVM_DEVICE = "/dev/kvm"
def smolmachines_preflight() -> None: def smolmachines_preflight() -> None:
"""Ensure the host can run the smolmachines backend before the """Ensure `smolvm` is on PATH before the launch flow runs.
launch flow starts. Called from `_resolve_plan`; surfaces a Called from `_resolve_plan`; gives the operator a clear
clear, actionable error instead of a cryptic `smolvm` failure install pointer rather than a cryptic FileNotFoundError
deep in launch. later. `gvproxy` is no longer required — see the PRD's design
pivot section."""
Checks `smolvm` is on PATH (both platforms) and, on Linux, if shutil.which("smolvm") is not None:
that `/dev/kvm` exists and is accessible. `gvproxy` is no return
longer required — see the PRD's design pivot section."""
if shutil.which("smolvm") is None:
die( die(
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on " "BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"PATH. Install with: " "PATH. Install with: "
@@ -34,29 +25,6 @@ def smolmachines_preflight() -> None:
"To use the legacy Docker backend instead, set " "To use the legacy Docker backend instead, set "
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker." "BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
) )
if platform.system() == "Linux":
_preflight_kvm()
def _preflight_kvm() -> None:
"""Linux-only: libkrun needs `/dev/kvm`. Distinguish 'KVM not
enabled' from 'no permission' so the operator knows which to
fix."""
if not os.path.exists(_KVM_DEVICE):
die(
f"BOT_BOTTLE_BACKEND=smolmachines needs {_KVM_DEVICE} on "
"Linux but it is missing. Enable KVM: load the kvm-intel "
"or kvm-amd kernel module (and confirm virtualization is "
"enabled in BIOS/firmware). To use the legacy Docker "
"backend instead, set BOT_BOTTLE_BACKEND=docker."
)
if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK):
die(
f"{_KVM_DEVICE} exists but is not readable/writable by the "
"current user. Add your user to the `kvm` group "
"(`sudo usermod -aG kvm \"$USER\"`) and re-login, or run "
"with access to the device."
)
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]: def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
-11
View File
@@ -1,11 +0,0 @@
"""Bottle lifecycle primitives."""
from .runner import BottleError, destroy, freeze, resume_headless, start_headless
__all__ = [
"BottleError",
"destroy",
"freeze",
"resume_headless",
"start_headless",
]
-258
View File
@@ -1,258 +0,0 @@
"""Public Python API for programmatic bottle orchestration.
Stable surface for bot-bottle-orchestrator (and other Python callers) to
drive bottles without invoking the CLI as a subprocess. Every function
converts ``Die`` and non-zero agent exit codes to ``BottleError`` so
callers use exception handling rather than inspecting return values.
The Protocol the orchestrator's ``BottleRunner`` targets looks like::
class BottleRunner(Protocol):
def start(self, agent: str, *, prompt: str, ...) -> str: ...
def resume(self, slug: str, *, prompt: str) -> None: ...
def freeze(self, slug: str) -> None: ...
def destroy(self, slug: str) -> None: ...
A ``SubprocessBottleRunner`` calls ``./cli.py`` for each operation. A
``ProgrammaticBottleRunner`` calls these functions directly; the Protocol
call sites in ``lifecycle.py`` are unchanged.
"""
from __future__ import annotations
from typing import Sequence, cast
from ..backend import BottleSpec
from ..backend.freeze import CommitCancelled, get_freezer
from .state import cleanup_state, clear_preserve_marker, read_metadata
from ..cli._common import USER_CWD
from ..cli.start import _launch_bottle, _peek_agent_bottle, _uniquify_label_headless
from ..log import Die
from ..manifest import ManifestError, ManifestIndex
class BottleError(Exception):
"""Raised when a bottle operation fails.
``exit_code`` carries the agent process's exit code when the failure is
a non-zero agent exit; 1 for all other failure modes (missing state,
backend errors, etc.)."""
def __init__(self, message: str, *, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def start_headless(
agent_name: str,
*,
prompt: str,
bottles: Sequence[str] | None = None,
label: str | None = None,
color: str | None = None,
backend_name: str | None = None,
copy_cwd: bool = False,
forge_env: dict[str, str] | None = None,
user_cwd: str | None = None,
) -> str:
"""Launch a new bottle headlessly. Returns the bottle slug.
``forge_env`` is passed through to the forge sidecar (not the agent)
when the bottle is forge-targeted; it carries the credentials and
context the sidecar needs to call the forge API.
Raises ``BottleError`` on configuration errors or if the agent exits
non-zero. The returned slug can be passed to ``freeze()``,
``resume_headless()``, or ``destroy()`` for subsequent lifecycle
operations."""
cwd = user_cwd or USER_CWD
try:
manifest = ManifestIndex.resolve(cwd)
manifest.require_agent(agent_name)
except (Die, ManifestError) as exc:
raise BottleError(str(exc)) from exc
if bottles:
bottle_names: tuple[str, ...] = tuple(bottles)
else:
default_bottle = _peek_agent_bottle(manifest, agent_name)
if not default_bottle:
raise BottleError(
f"agent '{agent_name}' has no default bottle; "
f"pass bottles=[...]"
)
bottle_names = (default_bottle,)
spec = BottleSpec(
manifest=manifest,
agent_name=agent_name,
copy_cwd=copy_cwd,
user_cwd=cwd,
label=_uniquify_label_headless(label or agent_name),
color=color or "",
bottle_names=bottle_names,
forge_env=dict(forge_env) if forge_env else {},
)
try:
slug, exit_code = _launch_bottle(
spec,
dry_run=False,
backend_name=backend_name,
assume_yes=True,
headless_prompt_text=prompt,
)
except Die as exc:
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
if exit_code != 0:
raise BottleError(
f"agent exited {exit_code} (slug={slug!r})", exit_code=exit_code
)
return slug
def resume_headless(
slug: str,
*,
prompt: str,
backend_name: str | None = None,
forge_env: dict[str, str] | None = None,
) -> None:
"""Resume a frozen bottle headlessly with ``prompt``.
``forge_env`` re-supplies forge context for the new session (the
sidecar is relaunched alongside the agent on resume).
Raises ``BottleError`` on missing state, backend errors, or non-zero
agent exit."""
metadata = read_metadata(slug)
if metadata is None:
raise BottleError(
f"no state recorded for slug {slug!r}; "
f"check ~/.bot-bottle/state/ or call start_headless() to create a new bottle"
)
try:
manifest = ManifestIndex.resolve(metadata.cwd or USER_CWD)
manifest.require_agent(metadata.agent_name)
except (Die, ManifestError) as exc:
raise BottleError(str(exc)) from exc
spec = BottleSpec(
manifest=manifest,
agent_name=metadata.agent_name,
copy_cwd=metadata.copy_cwd,
user_cwd=metadata.cwd or USER_CWD,
identity=metadata.identity,
bottle_names=tuple(metadata.bottle_names),
forge_env=dict(forge_env) if forge_env else {},
)
try:
_, exit_code = _launch_bottle(
spec,
dry_run=False,
backend_name=backend_name or metadata.backend or None,
assume_yes=True,
headless_prompt_text=prompt,
)
except Die as exc:
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
if exit_code != 0:
raise BottleError(
f"agent exited {exit_code} resuming {slug!r}", exit_code=exit_code
)
def freeze(slug: str, *, backend_name: str | None = None) -> None:
"""Freeze the named bottle to a resumable artifact.
Reads the bottle's backend from its metadata when ``backend_name`` is
not supplied. Raises ``BottleError`` if the freeze fails."""
metadata = read_metadata(slug)
resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker"
try:
get_freezer(resolved_backend).commit_slug(slug)
except CommitCancelled as exc:
raise BottleError(f"freeze cancelled for {slug!r}") from exc
except Die as exc:
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
def destroy(slug: str, *, backend_name: str | None = None) -> None:
"""Destroy the named bottle, removing all resources and state.
Brings down any running resources for ``slug``, then removes the
per-bottle state directory. Idempotent: a slug with no running
resources or no state directory is not an error."""
metadata = read_metadata(slug)
resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker"
try:
if resolved_backend == "docker":
_destroy_docker(slug)
elif resolved_backend == "smolmachines":
_destroy_smolmachines(slug)
# macos-container: the container is torn down inside the launch
# context manager; no persistent VM survives, so nothing extra is
# needed at destroy time beyond the state-dir removal below.
except Die as exc:
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
clear_preserve_marker(slug)
cleanup_state(slug)
# --- backend-specific helpers -----------------------------------------------
def _destroy_docker(slug: str) -> None:
"""Best-effort ``docker compose down`` for a Docker bottle.
No-op when the compose file is absent — the project was already
brought down (normal for a frozen bottle) or was never created."""
from ..backend.docker.compose import (
compose_down,
compose_file_path,
compose_project_name,
)
from .state import bottle_state_dir
state_dir = bottle_state_dir(slug)
compose_file = compose_file_path(state_dir)
if compose_file.exists():
compose_down(compose_project_name(slug), compose_file)
def _destroy_smolmachines(slug: str) -> None:
"""Best-effort stop + delete for a smolmachines bottle.
Both steps are best-effort: a machine that is already gone does not
cause an error; partial failures are logged as warnings."""
import subprocess
from ..log import warn
machine = f"bot-bottle-{slug}"
subprocess.run(
["smolvm", "machine", "stop", "--name", machine],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
r = subprocess.run(
["smolvm", "machine", "delete", "-f", machine],
capture_output=True,
text=True,
check=False,
)
if r.returncode != 0:
warn(
f"smolvm machine delete -f {machine!r} failed "
f"(may already be gone): {(r.stderr or '').strip()}"
)
__all__ = [
"BottleError",
"destroy",
"freeze",
"resume_headless",
"start_headless",
]
-364
View File
@@ -1,364 +0,0 @@
"""Per-bottle persistent state.
Holds optional per-bottle Dockerfile overrides, the transcript snapshot
the state-preservation helper saves before teardown, and the launch metadata that lets
`cli.py resume <identity>` reconstruct a bottle's spec. State
lives at:
~/.bot-bottle/state/<identity>/
metadata.json — agent_name + cwd + started_at (for resume)
Dockerfile — per-bottle override (absent → use repo's)
transcript/ — last snapshotted agent state (best-effort)
When the per-bottle Dockerfile is present, the launch step builds
the agent image with a per-bottle tag (bot-bottle-rebuilt-<id>)
from this file rather than the repo's. The build context is still
the repo root so the Dockerfile can COPY bot_bottle source files
the same way the original does.
Identity model:
- Every `cli.py start <agent>` mints a fresh identity via
`bottle_identity(agent_name)`: slug-prefix for readability plus a
5-char random suffix for parallel-safe uniqueness. The metadata
written at launch time pins (agent_name, cwd) to that identity.
- `cli.py resume <identity>` reads the metadata and re-launches a
bottle pinned to the same identity, picking up any per-bottle
Dockerfile and transcript snapshot.
"""
from __future__ import annotations
import dataclasses
import json
import secrets
import string
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from .. import supervise as _supervise
# Directory layout: ~/.bot-bottle/state/<identity>/...
_STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_COMMITTED_IMAGE_NAME = "committed-image"
_TRANSCRIPT_SUBDIR = "transcript"
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
# live here so chunk 3's `docker compose up` can find them at stable
# paths. Each sidecar's `prepare()` writes config + CAs into its own
# subdir; the launch step is unchanged today (still `docker cp`).
_EGRESS_SUBDIR = "egress"
_GIT_GATE_SUBDIR = "git-gate"
_SUPERVISE_SUBDIR = "supervise"
_AGENT_SUBDIR = "agent"
_METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's
# `list-egress-routes` MCP tool returns the current state —
# not a snapshot from launch time.
_LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
# Empty marker file. Session preservation writes it before teardown so
# cli.py's session-end cleanup knows to preserve the state dir for
# `cli.py resume <identity>`. Absent = clean up.
_PRESERVE_MARKER = ".preserve"
# 5 chars of base36 alphabet ≈ 60M combinations. Plenty for human
# operators starting bottles by hand; collision-free in practice.
_RANDOM_SUFFIX_LEN = 5
_SUFFIX_ALPHABET = string.ascii_lowercase + string.digits
def bottle_identity(agent_name: str) -> str:
"""Mint a fresh per-launch bottle identity. The slug-prefix is
`slugify(agent_name)` for readability; the suffix is 5 random
base36 chars so two simultaneous `start <agent>` invocations
don't collide on container/network names.
Every call produces a different identity (non-deterministic).
To continue an existing bottle's state, use the recorded
identity from BottleMetadata via `cli.py resume <identity>`,
not this function."""
from ..backend.docker import util as docker_mod
slug = docker_mod.slugify(agent_name)
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
return f"{slug}-{suffix}"
@dataclass(frozen=True)
class BottleMetadata:
"""Persistent record of how a bottle was launched, written at
start time and read by `cli.py resume`. Lives at
~/.bot-bottle/state/<identity>/metadata.json."""
identity: str
agent_name: str
cwd: str # empty string when --cwd was not passed
copy_cwd: bool
started_at: str # ISO 8601 UTC
# PRD 0018 chunk 3: derivable from identity via
# `compose_project_name(identity)`, but persisted explicitly so
# dashboard / cleanup / resume tooling can read it without
# importing the compose module. Empty string for state dirs
# written before chunk 3 (resume / inspect should fall back to
# deriving from identity in that case).
compose_project: str = ""
# PRD 0040: backend name ("docker" or "smolmachines"). Empty string
# for state dirs written before PRD 0040; callers default to "docker"
# for backward compatibility.
backend: str = ""
label: str = ""
color: str = ""
# Ordered bottle names selected at launch (issue #269). Empty tuple
# for state dirs written before this change; resume falls back to
# the agent's `bottle:` field in that case.
bottle_names: tuple[str, ...] = ()
def metadata_path(identity: str) -> Path:
return bottle_state_dir(identity) / _METADATA_NAME
def write_metadata(metadata: BottleMetadata) -> Path:
"""Persist `metadata` to ~/.bot-bottle/state/<identity>/metadata.json.
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
path = metadata_path(metadata.identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(dataclasses.asdict(metadata), indent=2) + "\n")
path.chmod(0o644)
return path
def read_metadata(identity: str) -> BottleMetadata | None:
"""Return the metadata for `identity`, or None if no state has
been recorded for it. Used by `cli.py resume` to reconstruct
the launch spec."""
path = metadata_path(identity)
if not path.is_file():
return None
raw = json.loads(path.read_text())
if not isinstance(raw, dict):
return None
raw_typed = cast(dict[str, object], raw)
raw_bottle_names = raw_typed.get("bottle_names", [])
bottle_names: tuple[str, ...] = ()
if isinstance(raw_bottle_names, list):
bottle_names = tuple(str(n) for n in raw_bottle_names if isinstance(n, str))
return BottleMetadata(
identity=str(raw_typed.get("identity", identity)),
agent_name=str(raw_typed.get("agent_name", "")),
cwd=str(raw_typed.get("cwd", "")),
copy_cwd=bool(raw_typed.get("copy_cwd", False)),
started_at=str(raw_typed.get("started_at", "")),
compose_project=str(raw_typed.get("compose_project", "")),
backend=str(raw_typed.get("backend", "")),
label=str(raw_typed.get("label", "")),
color=str(raw_typed.get("color", "")),
bottle_names=bottle_names,
)
def bottle_state_dir(identity: str) -> Path:
"""Per-bottle state directory on the host. Created lazily by the
write helpers; readers tolerate its absence."""
return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
def per_bottle_dockerfile_path(identity: str) -> Path:
return bottle_state_dir(identity) / _PER_BOTTLE_DOCKERFILE_NAME
def per_bottle_dockerfile(identity: str) -> str | None:
"""Return the per-bottle Dockerfile content if present, else
None. None means: use the provider or manifest Dockerfile."""
p = per_bottle_dockerfile_path(identity)
if p.is_file():
return p.read_text()
return None
def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
p = per_bottle_dockerfile_path(identity)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
p.chmod(0o644)
return p
def committed_image_path(identity: str) -> Path:
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
def write_committed_image(identity: str, image_tag: str) -> Path:
"""Persist the committed image tag for `identity`. The next
`cli.py resume <identity>` will boot from this image instead of
rebuilding from the Dockerfile."""
path = committed_image_path(identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(image_tag.strip() + "\n")
path.chmod(0o644)
return path
def read_committed_image(identity: str) -> str | None:
"""Return the committed image tag for `identity`, or None if no
commit has been recorded. Used by the Docker launch step to skip
the Dockerfile build when a committed snapshot exists."""
path = committed_image_path(identity)
if not path.is_file():
return None
tag = path.read_text().strip()
return tag or None
def per_bottle_image_tag(identity: str) -> str:
"""Image tag for a rebuilt bottle. Distinct from the base
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
the docker image cache."""
return f"bot-bottle-rebuilt-{identity}:latest"
def live_config_dir(identity: str) -> Path:
"""Per-bottle live-config dir. Bind-mounted read-only into the
supervise sidecar; the host's apply paths refresh the files on
every operator approval so the agent's `list-*` MCP tools always
return current state."""
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
def live_routes_path(identity: str) -> Path:
return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME
def live_allowlist_path(identity: str) -> Path:
return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME
def write_live_config(
identity: str, *, routes: str = "", allowlist: str = "",
) -> Path:
"""Initialise (or refresh) the live-config dir. Empty-string args
leave the existing file alone (caller passes only what it knows).
Returns the live-config dir path."""
d = live_config_dir(identity)
d.mkdir(parents=True, exist_ok=True)
if routes:
p = live_routes_path(identity)
p.write_text(routes)
p.chmod(0o644)
if allowlist:
p = live_allowlist_path(identity)
p.write_text(allowlist)
p.chmod(0o644)
return d
def transcript_snapshot_dir(identity: str) -> Path:
"""Where agent session snapshots are kept for resume flows."""
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------
#
# Each sidecar gets its own subdir under the bottle's state dir for
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
# land here; the state dir's normal cleanup (`cleanup_state`) reaps
# them along with everything else when the bottle session ends and
# nothing requested preservation.
def egress_state_dir(identity: str) -> Path:
"""State subdir for the egress sidecar: routes.yaml + the
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
return bottle_state_dir(identity) / _EGRESS_SUBDIR
def git_gate_state_dir(identity: str) -> Path:
"""State subdir for the git-gate sidecar: entrypoint + hooks +
per-upstream known_hosts. Bind-mount source from chunk 3
onward."""
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
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."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
def agent_state_dir(identity: str) -> Path:
"""State subdir for the agent's prepare-time scratch files: the
env file (docker --env-file source) and the prompt file."""
return bottle_state_dir(identity) / _AGENT_SUBDIR
# --- Preserve-on-close marker ----------------------------------------------
def preserve_marker_path(identity: str) -> Path:
return bottle_state_dir(identity) / _PRESERVE_MARKER
def mark_preserved(identity: str) -> Path:
"""Mark this bottle's state for preservation across session
teardown so cli.py's session-end cleanup leaves the state dir
intact for a subsequent `cli.py resume`."""
path = preserve_marker_path(identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return path
def is_preserved(identity: str) -> bool:
return preserve_marker_path(identity).exists()
def clear_preserve_marker(identity: str) -> None:
"""Idempotent removal. Called at fresh launch (start or resume)
so a marker left from a prior preserved session doesn't keep
state alive past the next normal session-end."""
try:
preserve_marker_path(identity).unlink()
except FileNotFoundError:
pass
def cleanup_state(identity: str) -> None:
"""Remove the per-bottle state dir entirely. Called by cli.py
when a bottle session ends and is_preserved(identity) is False.
Idempotent — missing dir is success."""
import shutil
state_dir = bottle_state_dir(identity)
if state_dir.is_dir():
shutil.rmtree(state_dir, ignore_errors=True)
__all__ = [
"BottleMetadata",
"agent_state_dir",
"bottle_identity",
"bottle_state_dir",
"cleanup_state",
"clear_preserve_marker",
"committed_image_path",
"egress_state_dir",
"git_gate_state_dir",
"is_preserved",
"mark_preserved",
"metadata_path",
"per_bottle_dockerfile",
"per_bottle_dockerfile_path",
"per_bottle_image_tag",
"preserve_marker_path",
"read_committed_image",
"read_metadata",
"supervise_state_dir",
"transcript_snapshot_dir",
"write_committed_image",
"write_metadata",
"write_per_bottle_dockerfile",
]
+361 -3
View File
@@ -1,6 +1,364 @@
"""Compatibility wrapper for per-bottle persistent state. """Per-bottle persistent state.
The implementation lives in :mod:`bot_bottle.bottle.state`. Holds optional per-bottle Dockerfile overrides, the transcript snapshot
the state-preservation helper saves before teardown, and the launch metadata that lets
`cli.py resume <identity>` reconstruct a bottle's spec. State
lives at:
~/.bot-bottle/state/<identity>/
metadata.json — agent_name + cwd + started_at (for resume)
Dockerfile — per-bottle override (absent → use repo's)
transcript/ — last snapshotted agent state (best-effort)
When the per-bottle Dockerfile is present, the launch step builds
the agent image with a per-bottle tag (bot-bottle-rebuilt-<id>)
from this file rather than the repo's. The build context is still
the repo root so the Dockerfile can COPY bot_bottle source files
the same way the original does.
Identity model:
- Every `cli.py start <agent>` mints a fresh identity via
`bottle_identity(agent_name)`: slug-prefix for readability plus a
5-char random suffix for parallel-safe uniqueness. The metadata
written at launch time pins (agent_name, cwd) to that identity.
- `cli.py resume <identity>` reads the metadata and re-launches a
bottle pinned to the same identity, picking up any per-bottle
Dockerfile and transcript snapshot.
""" """
from .bottle.state import * # noqa: F403 from __future__ import annotations
import dataclasses
import json
import secrets
import string
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from . import supervise as _supervise
# Directory layout: ~/.bot-bottle/state/<identity>/...
_STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_COMMITTED_IMAGE_NAME = "committed-image"
_TRANSCRIPT_SUBDIR = "transcript"
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
# live here so chunk 3's `docker compose up` can find them at stable
# paths. Each sidecar's `prepare()` writes config + CAs into its own
# subdir; the launch step is unchanged today (still `docker cp`).
_EGRESS_SUBDIR = "egress"
_GIT_GATE_SUBDIR = "git-gate"
_SUPERVISE_SUBDIR = "supervise"
_AGENT_SUBDIR = "agent"
_METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's
# `list-egress-routes` MCP tool returns the current state —
# not a snapshot from launch time.
_LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
# Empty marker file. Session preservation writes it before teardown so
# cli.py's session-end cleanup knows to preserve the state dir for
# `cli.py resume <identity>`. Absent = clean up.
_PRESERVE_MARKER = ".preserve"
# 5 chars of base36 alphabet ≈ 60M combinations. Plenty for human
# operators starting bottles by hand; collision-free in practice.
_RANDOM_SUFFIX_LEN = 5
_SUFFIX_ALPHABET = string.ascii_lowercase + string.digits
def bottle_identity(agent_name: str) -> str:
"""Mint a fresh per-launch bottle identity. The slug-prefix is
`slugify(agent_name)` for readability; the suffix is 5 random
base36 chars so two simultaneous `start <agent>` invocations
don't collide on container/network names.
Every call produces a different identity (non-deterministic).
To continue an existing bottle's state, use the recorded
identity from BottleMetadata via `cli.py resume <identity>`,
not this function."""
from .backend.docker import util as docker_mod
slug = docker_mod.slugify(agent_name)
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
return f"{slug}-{suffix}"
@dataclass(frozen=True)
class BottleMetadata:
"""Persistent record of how a bottle was launched, written at
start time and read by `cli.py resume`. Lives at
~/.bot-bottle/state/<identity>/metadata.json."""
identity: str
agent_name: str
cwd: str # empty string when --cwd was not passed
copy_cwd: bool
started_at: str # ISO 8601 UTC
# PRD 0018 chunk 3: derivable from identity via
# `compose_project_name(identity)`, but persisted explicitly so
# dashboard / cleanup / resume tooling can read it without
# importing the compose module. Empty string for state dirs
# written before chunk 3 (resume / inspect should fall back to
# deriving from identity in that case).
compose_project: str = ""
# PRD 0040: backend name ("docker" or "smolmachines"). Empty string
# for state dirs written before PRD 0040; callers default to "docker"
# for backward compatibility.
backend: str = ""
label: str = ""
color: str = ""
# Ordered bottle names selected at launch (issue #269). Empty tuple
# for state dirs written before this change; resume falls back to
# the agent's `bottle:` field in that case.
bottle_names: tuple[str, ...] = ()
def metadata_path(identity: str) -> Path:
return bottle_state_dir(identity) / _METADATA_NAME
def write_metadata(metadata: BottleMetadata) -> Path:
"""Persist `metadata` to ~/.bot-bottle/state/<identity>/metadata.json.
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
path = metadata_path(metadata.identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(dataclasses.asdict(metadata), indent=2) + "\n")
path.chmod(0o644)
return path
def read_metadata(identity: str) -> BottleMetadata | None:
"""Return the metadata for `identity`, or None if no state has
been recorded for it. Used by `cli.py resume` to reconstruct
the launch spec."""
path = metadata_path(identity)
if not path.is_file():
return None
raw = json.loads(path.read_text())
if not isinstance(raw, dict):
return None
raw_typed = cast(dict[str, object], raw)
raw_bottle_names = raw_typed.get("bottle_names", [])
bottle_names: tuple[str, ...] = ()
if isinstance(raw_bottle_names, list):
bottle_names = tuple(str(n) for n in raw_bottle_names if isinstance(n, str))
return BottleMetadata(
identity=str(raw_typed.get("identity", identity)),
agent_name=str(raw_typed.get("agent_name", "")),
cwd=str(raw_typed.get("cwd", "")),
copy_cwd=bool(raw_typed.get("copy_cwd", False)),
started_at=str(raw_typed.get("started_at", "")),
compose_project=str(raw_typed.get("compose_project", "")),
backend=str(raw_typed.get("backend", "")),
label=str(raw_typed.get("label", "")),
color=str(raw_typed.get("color", "")),
bottle_names=bottle_names,
)
def bottle_state_dir(identity: str) -> Path:
"""Per-bottle state directory on the host. Created lazily by the
write helpers; readers tolerate its absence."""
return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
def per_bottle_dockerfile_path(identity: str) -> Path:
return bottle_state_dir(identity) / _PER_BOTTLE_DOCKERFILE_NAME
def per_bottle_dockerfile(identity: str) -> str | None:
"""Return the per-bottle Dockerfile content if present, else
None. None means: use the provider or manifest Dockerfile."""
p = per_bottle_dockerfile_path(identity)
if p.is_file():
return p.read_text()
return None
def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
p = per_bottle_dockerfile_path(identity)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
p.chmod(0o644)
return p
def committed_image_path(identity: str) -> Path:
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
def write_committed_image(identity: str, image_tag: str) -> Path:
"""Persist the committed image tag for `identity`. The next
`cli.py resume <identity>` will boot from this image instead of
rebuilding from the Dockerfile."""
path = committed_image_path(identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(image_tag.strip() + "\n")
path.chmod(0o644)
return path
def read_committed_image(identity: str) -> str | None:
"""Return the committed image tag for `identity`, or None if no
commit has been recorded. Used by the Docker launch step to skip
the Dockerfile build when a committed snapshot exists."""
path = committed_image_path(identity)
if not path.is_file():
return None
tag = path.read_text().strip()
return tag or None
def per_bottle_image_tag(identity: str) -> str:
"""Image tag for a rebuilt bottle. Distinct from the base
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
the docker image cache."""
return f"bot-bottle-rebuilt-{identity}:latest"
def live_config_dir(identity: str) -> Path:
"""Per-bottle live-config dir. Bind-mounted read-only into the
supervise sidecar; the host's apply paths refresh the files on
every operator approval so the agent's `list-*` MCP tools always
return current state."""
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
def live_routes_path(identity: str) -> Path:
return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME
def live_allowlist_path(identity: str) -> Path:
return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME
def write_live_config(
identity: str, *, routes: str = "", allowlist: str = "",
) -> Path:
"""Initialise (or refresh) the live-config dir. Empty-string args
leave the existing file alone (caller passes only what it knows).
Returns the live-config dir path."""
d = live_config_dir(identity)
d.mkdir(parents=True, exist_ok=True)
if routes:
p = live_routes_path(identity)
p.write_text(routes)
p.chmod(0o644)
if allowlist:
p = live_allowlist_path(identity)
p.write_text(allowlist)
p.chmod(0o644)
return d
def transcript_snapshot_dir(identity: str) -> Path:
"""Where agent session snapshots are kept for resume flows."""
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------
#
# Each sidecar gets its own subdir under the bottle's state dir for
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
# land here; the state dir's normal cleanup (`cleanup_state`) reaps
# them along with everything else when the bottle session ends and
# nothing requested preservation.
def egress_state_dir(identity: str) -> Path:
"""State subdir for the egress sidecar: routes.yaml + the
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
return bottle_state_dir(identity) / _EGRESS_SUBDIR
def git_gate_state_dir(identity: str) -> Path:
"""State subdir for the git-gate sidecar: entrypoint + hooks +
per-upstream known_hosts. Bind-mount source from chunk 3
onward."""
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
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."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
def agent_state_dir(identity: str) -> Path:
"""State subdir for the agent's prepare-time scratch files: the
env file (docker --env-file source) and the prompt file."""
return bottle_state_dir(identity) / _AGENT_SUBDIR
# --- Preserve-on-close marker ----------------------------------------------
def preserve_marker_path(identity: str) -> Path:
return bottle_state_dir(identity) / _PRESERVE_MARKER
def mark_preserved(identity: str) -> Path:
"""Mark this bottle's state for preservation across session
teardown so cli.py's session-end cleanup leaves the state dir
intact for a subsequent `cli.py resume`."""
path = preserve_marker_path(identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return path
def is_preserved(identity: str) -> bool:
return preserve_marker_path(identity).exists()
def clear_preserve_marker(identity: str) -> None:
"""Idempotent removal. Called at fresh launch (start or resume)
so a marker left from a prior preserved session doesn't keep
state alive past the next normal session-end."""
try:
preserve_marker_path(identity).unlink()
except FileNotFoundError:
pass
def cleanup_state(identity: str) -> None:
"""Remove the per-bottle state dir entirely. Called by cli.py
when a bottle session ends and is_preserved(identity) is False.
Idempotent — missing dir is success."""
import shutil
state_dir = bottle_state_dir(identity)
if state_dir.is_dir():
shutil.rmtree(state_dir, ignore_errors=True)
__all__ = [
"BottleMetadata",
"agent_state_dir",
"bottle_identity",
"bottle_state_dir",
"cleanup_state",
"clear_preserve_marker",
"committed_image_path",
"egress_state_dir",
"git_gate_state_dir",
"is_preserved",
"mark_preserved",
"metadata_path",
"per_bottle_dockerfile",
"per_bottle_dockerfile_path",
"per_bottle_image_tag",
"preserve_marker_path",
"read_committed_image",
"read_metadata",
"supervise_state_dir",
"transcript_snapshot_dir",
"write_committed_image",
"write_metadata",
"write_per_bottle_dockerfile",
]
+2 -18
View File
@@ -7,10 +7,8 @@ from __future__ import annotations
import sys import sys
from ..errors import MissingEnvVarError
from ..log import Die, die, error from ..log import Die, die, error
from ..manifest import ManifestError from ..manifest import ManifestError
from ..store_manager import StoreManager
from ._common import PROG from ._common import PROG
from . import list as _list_mod from . import list as _list_mod
from .cleanup import cmd_cleanup from .cleanup import cmd_cleanup
@@ -76,25 +74,11 @@ def main(argv: list[str] | None = None) -> int:
if handler is None: if handler is None:
usage() usage()
die(f"unknown command: {command}") 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: try:
return handler(rest) or 0 return handler(rest) or 0
except MissingEnvVarError as e:
error(str(e))
return 1
except ManifestError as e: 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)) error(str(e))
return 1 return 1
except Die as e: except Die as e:
+1 -1
View File
@@ -14,7 +14,7 @@ import argparse
from ..backend import enumerate_active_agents from ..backend import enumerate_active_agents
from ..backend.freeze import CommitCancelled, get_freezer from ..backend.freeze import CommitCancelled, get_freezer
from ..bottle.state import read_metadata from ..bottle_state import read_metadata
from ..log import die from ..log import die
from ._common import PROG from ._common import PROG
from . import tui from . import tui
+2 -27
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import argparse import argparse
from ..backend import BottleSpec from ..backend import BottleSpec
from ..bottle.state import read_metadata from ..bottle_state import read_metadata
from ..log import die from ..log import die
from ..manifest import ManifestIndex from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
@@ -27,34 +27,12 @@ from .start import _launch_bottle
def cmd_resume(argv: list[str]) -> int: def cmd_resume(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
parser.add_argument("--dry-run", action="store_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( parser.add_argument(
"identity", "identity",
help="bottle identity from a prior `start` (see its session-end output)", help="bottle identity from a prior `start` (see its session-end output)",
) )
args = parser.parse_args(argv) 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) metadata = read_metadata(args.identity)
if metadata is None: if metadata is None:
die( die(
@@ -74,11 +52,8 @@ def cmd_resume(argv: list[str]) -> int:
bottle_names=tuple(metadata.bottle_names), bottle_names=tuple(metadata.bottle_names),
) )
backend_name = metadata.backend or None backend_name = metadata.backend or None
_, rc = _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=args.dry_run, dry_run=args.dry_run,
backend_name=backend_name, backend_name=backend_name,
assume_yes=args.headless,
headless_prompt_text=args.prompt or "",
) )
return rc
+9 -26
View File
@@ -31,7 +31,7 @@ from ..backend import (
) )
from ..backend.docker import util as docker_mod from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_plan import DockerBottlePlan
from ..bottle.state import ( from ..bottle_state import (
cleanup_state, cleanup_state,
is_preserved, is_preserved,
mark_preserved, mark_preserved,
@@ -108,13 +108,6 @@ def cmd_start(argv: list[str]) -> int:
agent_name: str | None = args.name agent_name: str | None = args.name
if agent_name is None: 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( agent_name = tui.filter_select(
manifest.all_agent_names, manifest.all_agent_names,
title="Select agent", title="Select agent",
@@ -151,12 +144,11 @@ def cmd_start(argv: list[str]) -> int:
color=color, color=color,
bottle_names=bottle_names, bottle_names=bottle_names,
) )
_, rc = _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=dry_run, dry_run=dry_run,
backend_name=backend_name, backend_name=backend_name,
) )
return rc
# --- Headless launch ----------------------------------------------------- # --- Headless launch -----------------------------------------------------
@@ -210,16 +202,14 @@ def _start_headless(
label=label, label=label,
color=args.color or "", color=args.color or "",
bottle_names=bottle_names, bottle_names=bottle_names,
headless=True,
) )
_, rc = _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=dry_run, dry_run=dry_run,
backend_name=backend_name, backend_name=backend_name,
assume_yes=True, assume_yes=True,
headless_prompt_text=prompt, headless_prompt_text=prompt,
) )
return rc
def _uniquify_label_headless(label: str) -> str: def _uniquify_label_headless(label: str) -> str:
@@ -245,7 +235,7 @@ def prepare_with_preflight(
spec: BottleSpec, spec: BottleSpec,
*, *,
stage_dir: Path, stage_dir: Path,
render_preflight: Callable[[DockerBottlePlan, str], None], render_preflight: Callable[[DockerBottlePlan], None],
prompt_yes: Callable[[], bool], prompt_yes: Callable[[], bool],
dry_run: bool = False, dry_run: bool = False,
backend_name: str | None = None, backend_name: str | None = None,
@@ -266,7 +256,7 @@ def prepare_with_preflight(
plan = backend.prepare(spec, stage_dir=stage_dir) plan = backend.prepare(spec, stage_dir=stage_dir)
identity = _identity_from_plan(plan) identity = _identity_from_plan(plan)
render_preflight(plan, backend.name) render_preflight(plan)
if dry_run: if dry_run:
info("dry-run requested; not starting container.") info("dry-run requested; not starting container.")
@@ -393,9 +383,8 @@ def _text_prompt_yes() -> bool:
def _text_render_preflight(): def _text_render_preflight():
def _render(plan: DockerBottlePlan, backend_name: str) -> None: def _render(plan: DockerBottlePlan) -> None:
print(file=sys.stderr) print(file=sys.stderr)
print(f"backend: {backend_name}", file=sys.stderr)
print(_manifest_to_yaml(plan.manifest), file=sys.stderr) print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
return _render return _render
@@ -508,16 +497,11 @@ def _launch_bottle(
backend_name: str | None = None, backend_name: str | None = None,
assume_yes: bool = False, assume_yes: bool = False,
headless_prompt_text: str = "", headless_prompt_text: str = "",
) -> tuple[str, int]: ) -> int:
"""Shared launch core for `start` and `resume`. Builds the plan, """Shared launch core for `start` and `resume`. Builds the plan,
prints / dry-runs / prompts as appropriate, brings the bottle up, prints / dry-runs / prompts as appropriate, brings the bottle up,
attaches claude, and prints the resume hint on session end. attaches claude, and prints the resume hint on session end.
Returns ``(slug, exit_code)`` where ``slug`` is the bottle identity
(empty string when the launch was aborted before a slug was minted)
and ``exit_code`` is the agent process's exit code (0 on clean exit
or when launch was aborted before the agent ran).
`assume_yes` skips the interactive y/N confirmation (headless / `assume_yes` skips the interactive y/N confirmation (headless /
orchestrator launches), where there is no human at the prompt. orchestrator launches), where there is no human at the prompt.
@@ -526,7 +510,6 @@ def _launch_bottle(
agent receives the initial task without interactive input.""" agent receives the initial task without interactive input."""
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage.")) stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
identity = "" identity = ""
exit_code = 0
try: try:
plan, identity = prepare_with_preflight( plan, identity = prepare_with_preflight(
spec, spec,
@@ -537,7 +520,7 @@ def _launch_bottle(
backend_name=backend_name, backend_name=backend_name,
) )
if plan is None: if plan is None:
return identity, 0 return 0
backend = get_bottle_backend(backend_name) backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle: with backend.launch(plan) as bottle:
@@ -564,7 +547,7 @@ def _launch_bottle(
# Ctrl-Cs / OOM kills before cleanup removes the state dir. # Ctrl-Cs / OOM kills before cleanup removes the state dir.
if agent_provider_template == "claude": if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code) capture_claude_session_state(identity, exit_code)
return identity, exit_code return 0
finally: finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
# sources under state/<slug>/. If we never reached the # sources under state/<slug>/. If we never reached the
+17 -10
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise from .. import supervise as _supervise
from ..bottle.state import read_metadata from ..bottle_state import read_metadata
from ..backend.docker.egress_apply import ( from ..backend.docker.egress_apply import (
EgressApplyError, EgressApplyError,
applicator as _docker_applicator, applicator as _docker_applicator,
@@ -45,7 +45,7 @@ from ..supervise import (
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
list_all_pending_proposals, list_pending_proposals,
render_diff, render_diff,
write_audit_entry, write_audit_entry,
write_response, write_response,
@@ -63,9 +63,10 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
@dataclass(frozen=True) @dataclass(frozen=True)
class QueuedProposal: class QueuedProposal:
"""A pending proposal from the supervise queue.""" """A pending proposal plus the queue dir it was found in."""
proposal: Proposal proposal: Proposal
queue_dir: Path
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
@@ -85,11 +86,16 @@ def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
"""Collect pending proposals across bottles.""" """Walk ~/.bot-bottle/queue/* and collect pending proposals."""
out = [ queue_root = _supervise.bot_bottle_root() / "queue"
QueuedProposal(proposal=proposal) if not queue_root.is_dir():
for proposal in list_all_pending_proposals() 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))
out.sort(key=lambda q: q.proposal.arrival_timestamp) out.sort(key=lambda q: q.proposal.arrival_timestamp)
return out return out
@@ -112,6 +118,7 @@ def _detail_lines(
(f"tool: {p.tool}", 0), (f"tool: {p.tool}", 0),
(f"id: {p.id}", 0), (f"id: {p.id}", 0),
(f"arrived: {p.arrival_timestamp}", 0), (f"arrived: {p.arrival_timestamp}", 0),
(f"queue: {qp.queue_dir}", 0),
("", 0), ("", 0),
("justification:", 0), ("justification:", 0),
] ]
@@ -158,7 +165,7 @@ def approve(
notes=notes, notes=notes,
final_file=final_file, final_file=final_file,
) )
write_response(qp.proposal.bottle_slug, response) write_response(qp.queue_dir, response)
_write_audit( _write_audit(
qp, action=status, notes=notes, qp, action=status, notes=notes,
diff_before=diff_before, diff_after=diff_after, diff_before=diff_before, diff_after=diff_after,
@@ -172,7 +179,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
notes=reason, notes=reason,
final_file=None, final_file=None,
) )
write_response(qp.proposal.bottle_slug, response) write_response(qp.queue_dir, response)
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") _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 # to it) works against egress's bumped TLS without the agent needing
# local DNS. # local DNS.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep iproute2 dnsutils \ && apt-get install -y --no-install-recommends git ca-certificates curl ripgrep \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by claude-code itself # App-specific deps. Python isn't required by claude-code itself
+17 -5
View File
@@ -23,8 +23,9 @@ from ...agent_provider import (
provider_startup_args, provider_startup_args,
) )
from ...backend.docker import util as docker_mod from ...backend.docker import util as docker_mod
from ...egress import EgressRoute from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
from ...log import die, info, warn from ...log import die, info, warn
from .claude_auth import claude_host_access_token
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -115,7 +116,6 @@ class ClaudeAgentProvider(AgentProvider):
color: str = "", color: str = "",
provider_settings: dict[str, object] | None = None, provider_settings: dict[str, object] | None = None,
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del forward_host_credentials, host_env
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
startup_args = provider_startup_args(provider_settings) startup_args = provider_startup_args(provider_settings)
guest_home = self.guest_home guest_home = self.guest_home
@@ -177,13 +177,24 @@ class ClaudeAgentProvider(AgentProvider):
claude_settings, claude_settings,
f"{guest_home}/.claude/settings.json", f"{guest_home}/.claude/settings.json",
)) ))
provisioned_env: dict[str, str] = {}
if forward_host_credentials:
_host_env = host_env or dict(os.environ)
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
claude_host_access_token(_host_env)
)
cred_token_ref = (
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
else auth_token
)
egress_routes = (EgressRoute( egress_routes = (EgressRoute(
host="api.anthropic.com", host="api.anthropic.com",
auth_scheme="Bearer" if auth_token else "", auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
token_ref=auth_token, token_ref=cred_token_ref,
),) ),)
hidden_env_names: frozenset[str] = frozenset() hidden_env_names: frozenset[str] = frozenset()
if auth_token: if auth_token or forward_host_credentials:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
@@ -205,6 +216,7 @@ class ClaudeAgentProvider(AgentProvider):
files=tuple(files), files=tuple(files),
egress_routes=egress_routes, egress_routes=egress_routes,
hidden_env_names=hidden_env_names, hidden_env_names=hidden_env_names,
provisioned_env=provisioned_env,
) )
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
+114
View File
@@ -0,0 +1,114 @@
"""Host Claude auth helpers.
Reads the host's Claude Code credentials and returns only the access
token needed by egress. Does not expose refresh tokens or raw payloads.
Credential storage by platform:
Linux ~/.claude/.credentials.json
macOS macOS Keychain, service "Claude Code-credentials"
(file path is tried first; Keychain is the fallback)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from ...log import die
_KEYCHAIN_SERVICE = "Claude Code-credentials"
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
env = os.environ if host_env is None else host_env
home = env.get("HOME")
if home:
return Path(home) / ".claude" / ".credentials.json"
return Path.home() / ".claude" / ".credentials.json"
def _read_keychain() -> dict[str, object] | None:
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
if sys.platform != "darwin":
return None
try:
result = subprocess.run(
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
capture_output=True,
text=True,
timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if result.returncode != 0 or not result.stdout.strip():
return None
try:
raw = json.loads(result.stdout.strip())
except json.JSONDecodeError:
return None
return raw if isinstance(raw, dict) else None
def claude_host_access_token(
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> str:
path = claude_auth_path(host_env)
raw: dict[str, object] | None = None
if path.is_file():
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
if not isinstance(raw, dict):
die(f"claude host credentials: {path} must contain a JSON object")
else:
raw = _read_keychain()
if raw is None:
die(
f"claude host credentials: auth file missing at {path} and "
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
oauth = raw.get("claudeAiOauth")
if not isinstance(oauth, dict):
die(
"claude host credentials: claudeAiOauth is missing from credentials. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
access_token = oauth.get("accessToken")
if not isinstance(access_token, str) or not access_token:
die(
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
"Run `claude login` on the host and restart the bottle."
)
# expiresAt is in milliseconds
expires_at = oauth.get("expiresAt")
if isinstance(expires_at, (int, float)):
check_now = now or datetime.now(timezone.utc)
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
if exp_dt <= check_now:
die(
"claude host credentials: host Claude access token is expired. "
"Run `claude login` on the host and restart the bottle."
)
return access_token
__all__ = [
"claude_auth_path",
"claude_host_access_token",
]
+3 -10
View File
@@ -34,12 +34,6 @@ if TYPE_CHECKING:
_SUPERVISE_MCP_NAME = "supervise" _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: def _skills_dir(guest_home: str) -> str:
@@ -56,7 +50,7 @@ def _prompt_path(guest_home: str) -> str:
_RUNTIME = AgentProviderRuntime( _RUNTIME = AgentProviderRuntime(
template="codex", template="codex",
command=_CODEX_CLI, command="codex",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
@@ -151,8 +145,7 @@ class CodexAgentProvider(AgentProvider):
"env", "env",
f"HOME={guest_home}", f"HOME={guest_home}",
f"CODEX_HOME={auth_dir}", f"CODEX_HOME={auth_dir}",
f"PATH={_CODEX_CLI_PATH}", "codex", "login", "status",
_CODEX_CLI, "login", "status",
), ( ), (
"codex host credentials: dummy auth was copied into the " "codex host credentials: dummy auth was copied into the "
"guest, but Codex did not accept it" "guest, but Codex did not accept it"
@@ -274,7 +267,7 @@ class CodexAgentProvider(AgentProvider):
return return
info(f"registering supervise MCP server in agent codex config → {supervise_url}") info(f"registering supervise MCP server in agent codex config → {supervise_url}")
r = bottle.exec( r = bottle.exec(
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url " f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
f"{shlex.quote(supervise_url)}", f"{shlex.quote(supervise_url)}",
user="node", user="node",
) )
+25 -138
View File
@@ -1,165 +1,52 @@
"""Forge abstraction (PRD forge-native-integration, chunk 3). """Scoped forge wrapper: read-anywhere / write-scoped access control.
The `Forge` abstract class is the provider-agnostic surface a forge `ScopedForge` wraps any forge object and restricts write operations to
sidecar dispatches to: read issues/comments, post comments, edit the set of issue/PR numbers the agent is explicitly assigned to. Read
descriptions, and the membership / PR lookups the orchestrator needs. operations always pass through unconditionally.
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 from __future__ import annotations
import abc from typing import Any
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True) class ScopedForge:
class Issue: """Delegates all forge calls to an inner forge, raising `PermissionError`
"""A forge issue (not a PR — see `PullRequest`).""" on write calls for numbers outside the assigned scope."""
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__( def __init__(
self, self,
inner: Forge, forge: Any,
*, *,
assigned_issue: int, assigned_issue: int,
assigned_prs: Iterable[int] = (), assigned_prs: list[int],
) -> None: ) -> None:
self._inner = inner self._forge = forge
self._assigned_issue = assigned_issue self._allowed_writes: frozenset[int] = frozenset({assigned_issue, *assigned_prs})
self._writable = {assigned_issue, *assigned_prs}
@property
def writable(self) -> frozenset[int]:
return frozenset(self._writable)
def _check_write(self, number: int) -> None: def _check_write(self, number: int) -> None:
if number not in self._writable: if number not in self._allowed_writes:
allowed = ", ".join(str(n) for n in sorted(self._writable)) raise PermissionError(
raise ForgeScopeError( f"write to #{number} is outside the assigned scope "
f"write to #{number} denied: out of assigned scope " f"(allowed: {sorted(self._allowed_writes)})"
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: def is_org_member(self, org: str, username: str) -> bool:
return self._inner.is_org_member(org, username) return self._forge.is_org_member(org, username)
def get_pr_for_issue(self, number: int) -> int | None: def read_issue(self, number: int) -> dict[str, Any]:
return self._inner.get_pr_for_issue(number) return self._forge.read_issue(number)
def is_pr_open(self, number: int) -> bool: def read_pr(self, number: int) -> dict[str, Any]:
return self._inner.is_pr_open(number) return self._forge.read_pr(number)
# --- write-scoped: check then delegate -------------------------------- def read_comments(self, number: int) -> list[dict[str, Any]]:
return self._forge.read_comments(number)
def post_comment(self, number: int, body: str) -> None: def post_comment(self, number: int, body: str) -> None:
self._check_write(number) self._check_write(number)
self._inner.post_comment(number, body) self._forge.post_comment(number, body)
def update_description(self, number: int, body: str) -> None: def update_description(self, number: int, body: str) -> None:
self._check_write(number) self._check_write(number)
self._inner.update_description(number, body) self._forge.update_description(number, body)
+70 -132
View File
@@ -1,16 +1,12 @@
"""Gitea HTTP client + `GiteaForge` (PRD forge-native-integration, chunk 3). """Gitea API client and forge adapter (PRD prd-new: fold orchestrator).
`GiteaClient` is the thin stdlib-only HTTP transport (mirrors `GiteaClient` is a thin HTTP wrapper (stdlib `urllib.request` only no
`deploy_key_provisioner.py`: `urllib.request`, bounded timeouts, new runtime dependencies). `GiteaForge` composes a client and exposes
structured error bodies). `GiteaForge` adapts it to the provider-agnostic the forge protocol used by the orchestrator's sidecar and lifecycle.
`Forge` surface.
Unlike the option-2 design, the token is held here (the sidecar process Required Gitea token scopes:
owns it) and passed to the client directly there is no agent-side - Repository: Read & Write (issues, comments, PR descriptions)
cred-proxy route, because the agent never makes forge calls. The HTTP - Organization: Read (org membership check)
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 from __future__ import annotations
@@ -20,155 +16,97 @@ import urllib.error
import urllib.request import urllib.request
from typing import Any from typing import Any
from ..forge.base import Comment, Forge, Issue, PullRequest _TIMEOUT_SECS = 30
# Bound every Gitea call: a hung instance must not stall the sidecar.
_API_TIMEOUT_SECS = 30
class GiteaClient: class GiteaClient:
"""Thin authenticated HTTP client for one repo's Gitea API. """Low-level HTTP wrapper for the Gitea REST API."""
`api_url` is the API base *including* `/api/v1` (matching the def __init__(
`FORGE_GITEA_API` env var), e.g. `https://gitea.example.com/api/v1`. self, *, api_url: str, owner: str, repo: str, token: str
""" ) -> None:
self._base = api_url.rstrip("/")
def __init__(self, *, api_url: str, owner: str, repo: str, token: str) -> None:
self._api_url = api_url.rstrip("/")
self._owner = owner self._owner = owner
self._repo = repo self._repo = repo
self._token = token self._headers = {
"Authorization": f"token {token}",
# --- low-level request ------------------------------------------------- "Content-Type": "application/json",
"Accept": "application/json",
}
def _request( def _request(
self, method: str, path: str, *, body: dict[str, Any] | None = None self,
) -> tuple[int, Any]: method: str,
"""Issue an authenticated request. Returns `(status, parsed_json)`; path: str,
parsed_json is None when the response has no body. Raises body: dict[str, Any] | None = None,
`RuntimeError` on any non-2xx except where callers special-case ) -> Any:
the HTTPError themselves (membership 404).""" url = f"{self._base}{path}"
url = f"{self._api_url}{path}"
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
headers = {"Authorization": f"token {self._token}"} req = urllib.request.Request(
if data is not None: url, data=data, headers=self._headers, method=method
headers["Content-Type"] = "application/json" )
req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp:
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS) as resp:
raw = resp.read() raw = resp.read()
parsed = json.loads(raw) if raw else None return 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: def is_org_member(self, org: str, username: str) -> bool:
"""GET /orgs/{org}/members/{username}: 2xx → member, 404 → not. url = f"{self._base}/orgs/{org}/members/{username}"
Other errors propagate so a misconfigured token fails loudly.""" req = urllib.request.Request(url, headers=self._headers, method="GET")
url = f"{self._api_url}/orgs/{org}/members/{username}"
req = urllib.request.Request(
url, headers={"Authorization": f"token {self._token}"}, method="GET"
)
try: try:
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS): urllib.request.urlopen(req, timeout=_TIMEOUT_SECS).close()
return True return True
except urllib.error.HTTPError as exc: except urllib.error.HTTPError:
if exc.code == 404:
return False 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]: def get_issue(self, number: int) -> dict[str, Any]:
_status, body = self._request("GET", self._repo_path(f"/issues/{number}")) return self._request("GET", f"/repos/{self._owner}/{self._repo}/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]: def get_pull(self, number: int) -> dict[str, Any]:
_status, body = self._request("GET", self._repo_path(f"/pulls/{number}")) return self._request("GET", f"/repos/{self._owner}/{self._repo}/pulls/{number}")
return body or {}
def list_comments(self, number: int) -> list[dict[str, Any]]:
return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}/comments")
def create_comment(self, number: int, body: str) -> None:
self._request(
"POST",
f"/repos/{self._owner}/{self._repo}/issues/{number}/comments",
{"body": body},
)
def update_issue(self, number: int, body: str) -> None:
self._request(
"PATCH",
f"/repos/{self._owner}/{self._repo}/issues/{number}",
{"body": body},
)
class GiteaForge(Forge): class GiteaForge:
"""`Forge` over a `GiteaClient`.""" """Adapts `GiteaClient` to the forge protocol expected by the orchestrator.
The forge protocol is duck-typed: any object with `is_org_member`,
`read_issue`, `read_pr`, `read_comments`, `post_comment`, and
`update_description` methods satisfies it.
"""
def __init__(self, client: GiteaClient) -> None: def __init__(self, client: GiteaClient) -> None:
self._client = client 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: def is_org_member(self, org: str, username: str) -> bool:
return self._client.is_org_member(org, username) return self._client.is_org_member(org, username)
def get_pr_for_issue(self, number: int) -> int | None: def read_issue(self, number: int) -> dict[str, Any]:
"""Gitea models a PR as an issue with the same number, exposing a return self._client.get_issue(number)
`pull_request` object on the issue. When the queried number is
itself a PR, return it; otherwise None. (The orchestrator tracks
the issuePR 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: def read_pr(self, number: int) -> dict[str, Any]:
return self.read_pr(number).state == "open" return self._client.get_pull(number)
def read_comments(self, number: int) -> list[dict[str, Any]]:
return self._client.list_comments(number)
def _read_error_body(exc: urllib.error.HTTPError) -> str: def post_comment(self, number: int, body: str) -> None:
try: self._client.create_comment(number, body)
return exc.read().decode("utf-8", errors="replace")
except Exception: # pylint: disable=broad-exception-caught def update_description(self, number: int, body: str) -> None:
return "" self._client.update_issue(number, body)
+54 -88
View File
@@ -1,37 +1,25 @@
"""Forge state persistence (PRD forge-native-integration, chunk 2). """Forge state persistence for the orchestrator (PRD prd-new: fold orchestrator).
The orchestrator tracks one record per forge-targeted issue so it can `ForgeState` is a dataclass that mirrors the orchestrator's `RunRecord`
map an incoming webhook back to the bottle handling it, drive the field-for-field, held here so the store implementation is in bot-bottle
freeze / rehydrate loop, and run the watchdog. where the Gitea contrib lives.
State is stored in a local SQLite database in `~/.bot-bottle/`. Access `SqliteForgeStateStore` backs it with a single SQLite table. The DB path
goes through the thin `ForgeStateStore` CRUD interface so the backing is optional; passing `None` uses `:memory:` (useful for tests and status
store (location or engine) can be swapped without touching callers; commands that don't need persistence).
`SqliteForgeStateStore` is the first implementation.
""" """
from __future__ import annotations from __future__ import annotations
import abc
import json import json
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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 @dataclass
class ForgeState: class ForgeState:
"""One forge-targeted issue's bottle lifecycle record.""" """Persisted state for one forge-targeted issue's bottle lifecycle."""
owner: str owner: str
repo: str repo: str
@@ -42,78 +30,59 @@ class ForgeState:
backend_name: str = "" backend_name: str = ""
agent_git_user: str = "" agent_git_user: str = ""
pr_number: int | None = None pr_number: int | None = None
status: str = STATUS_RUNNING status: str = ""
last_checkin_at: str = "" last_checkin_at: str = ""
class ForgeStateStore(abc.ABC): _DDL = """
"""Thin CRUD surface over forge state. Implementations back it with a CREATE TABLE IF NOT EXISTS forge_state (
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, owner TEXT NOT NULL,
repo TEXT NOT NULL, repo TEXT NOT NULL,
issue_number INTEGER NOT NULL, issue_number INTEGER NOT NULL,
slug TEXT NOT NULL, slug TEXT NOT NULL,
agent_name TEXT NOT NULL, agent_name TEXT NOT NULL,
bottle_names TEXT NOT NULL, bottle_names TEXT NOT NULL DEFAULT '[]',
backend_name TEXT NOT NULL, backend_name TEXT NOT NULL DEFAULT '',
agent_git_user TEXT NOT NULL, agent_git_user TEXT NOT NULL DEFAULT '',
pr_number INTEGER, pr_number INTEGER,
status TEXT NOT NULL, status TEXT NOT NULL DEFAULT '',
last_checkin_at TEXT NOT NULL, last_checkin_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (owner, repo, issue_number) PRIMARY KEY (owner, repo, issue_number)
) )
""" """
)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path) class SqliteForgeStateStore:
conn.row_factory = sqlite3.Row """SQLite-backed `ForgeState` store.
return conn
Thread-safety: a single connection is used; callers that share a
store across threads must serialise access externally.
"""
def __init__(self, db_path: Path | None) -> None:
path = str(db_path) if db_path is not None else ":memory:"
self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute(_DDL)
self._conn.commit()
def upsert(self, state: ForgeState) -> None: def upsert(self, state: ForgeState) -> None:
with self._connect() as conn: self._conn.execute(
conn.execute(
""" """
INSERT OR REPLACE INTO forge_state ( INSERT INTO forge_state
owner, repo, issue_number, slug, agent_name, (owner, repo, issue_number, slug, agent_name,
bottle_names, backend_name, agent_git_user, bottle_names, backend_name, agent_git_user,
pr_number, status, last_checkin_at pr_number, status, last_checkin_at)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner, repo, issue_number) DO UPDATE SET
slug = excluded.slug,
agent_name = excluded.agent_name,
bottle_names = excluded.bottle_names,
backend_name = excluded.backend_name,
agent_git_user = excluded.agent_git_user,
pr_number = excluded.pr_number,
status = excluded.status,
last_checkin_at = excluded.last_checkin_at
""", """,
( (
state.owner, state.owner,
@@ -129,30 +98,27 @@ class SqliteForgeStateStore(ForgeStateStore):
state.last_checkin_at, state.last_checkin_at,
), ),
) )
self._conn.commit()
def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None: def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None:
with self._connect() as conn: row = self._conn.execute(
row = conn.execute( "SELECT * FROM forge_state WHERE owner=? AND repo=? AND issue_number=?",
"SELECT * FROM forge_state "
"WHERE owner = ? AND repo = ? AND issue_number = ?",
(owner, repo, issue_number), (owner, repo, issue_number),
).fetchone() ).fetchone()
return _row_to_state(row) if row is not None else None return _row_to_state(row) if row is not None else None
def delete(self, owner: str, repo: str, issue_number: int) -> None: def delete(self, owner: str, repo: str, issue_number: int) -> None:
with self._connect() as conn: self._conn.execute(
conn.execute( "DELETE FROM forge_state WHERE owner=? AND repo=? AND issue_number=?",
"DELETE FROM forge_state "
"WHERE owner = ? AND repo = ? AND issue_number = ?",
(owner, repo, issue_number), (owner, repo, issue_number),
) )
self._conn.commit()
def all(self) -> list[ForgeState]: def all(self) -> list[ForgeState]:
with self._connect() as conn: rows = self._conn.execute(
rows = conn.execute(
"SELECT * FROM forge_state ORDER BY owner, repo, issue_number" "SELECT * FROM forge_state ORDER BY owner, repo, issue_number"
).fetchall() ).fetchall()
return [_row_to_state(row) for row in rows] return [_row_to_state(r) for r in rows]
def _row_to_state(row: sqlite3.Row) -> ForgeState: def _row_to_state(row: sqlite3.Row) -> ForgeState:
-59
View File
@@ -1,59 +0,0 @@
"""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"]
+6 -7
View File
@@ -23,13 +23,13 @@ from .egress_addon_core import (
PathMatch as CorePathMatch, PathMatch as CorePathMatch,
Route, Route,
) )
from .errors import MissingEnvVarError
from .log import die from .log import die
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import ManifestBottle from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
EGRESS_HOSTNAME = "egress" EGRESS_HOSTNAME = "egress"
@@ -355,18 +355,16 @@ def egress_resolve_token_values(
for token_env, token_ref in token_env_map.items(): for token_env, token_ref in token_env_map.items():
value = host_env.get(token_ref) value = host_env.get(token_ref)
if value is None: if value is None:
raise MissingEnvVarError( die(
token_ref,
f"egress: host env var '{token_ref}' is unset. Set it " f"egress: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding auth block " f"before launching, or remove the corresponding auth block "
f"from bottle.egress.routes.", f"from bottle.egress.routes."
) )
if not value: if not value:
raise MissingEnvVarError( die(
token_ref,
f"egress: host env var '{token_ref}' is empty. The " f"egress: host env var '{token_ref}' is empty. The "
f"egress will not inject an empty token; set it to " 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 out[token_env] = value
return out return out
@@ -400,6 +398,7 @@ class Egress(ABC):
) )
__all__ = [ __all__ = [
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
"CODEX_HOST_CREDENTIAL_TOKEN_REF", "CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME", "EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME", "EGRESS_ROUTES_FILENAME",
+9 -6
View File
@@ -79,13 +79,14 @@ class EgressAddon:
# only — a restart re-prompts. Mutated only from the asyncio loop that # only — a restart re-prompts. Mutated only from the asyncio loop that
# runs the addon hooks, so no lock is needed. # runs the addon hooks, so no lock is needed.
self.safe_tokens: set[str] = set() 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._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True) self._reload(initial=True)
self._install_sighup() self._install_sighup()
def _supervise_available(self) -> bool: def _supervise_available(self) -> bool:
return bool(self._supervise_slug) return bool(self._supervise_queue_dir and self._supervise_slug)
def _reload(self, *, initial: bool = False) -> None: def _reload(self, *, initial: bool = False) -> None:
try: try:
@@ -392,8 +393,9 @@ class EgressAddon:
justification=_TOKEN_ALLOW_JUSTIFICATION, justification=_TOKEN_ALLOW_JUSTIFICATION,
current_file_hash=_sv.sha256_hex(payload), current_file_hash=_sv.sha256_hex(payload),
) )
queue_dir = Path(self._supervise_queue_dir)
try: try:
_sv.write_proposal(proposal) _sv.write_proposal(queue_dir, proposal)
except OSError as e: except OSError as e:
sys.stderr.write( sys.stderr.write(
f"egress: could not queue token-allow proposal: {e}; " f"egress: could not queue token-allow proposal: {e}; "
@@ -409,8 +411,8 @@ class EgressAddon:
**self._req_ctx(flow), **self._req_ctx(flow),
}) + "\n") }) + "\n")
response = await self._await_token_response(proposal.id) response = await self._await_token_response(queue_dir, proposal.id)
_sv.archive_proposal(self._supervise_slug, proposal.id) _sv.archive_proposal(queue_dir, proposal.id)
if response is not None and response.status in ( if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED, _sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
@@ -437,15 +439,16 @@ class EgressAddon:
async def _await_token_response( async def _await_token_response(
self, self,
queue_dir: Path,
proposal_id: str, proposal_id: str,
) -> "_sv.Response | None": ) -> "_sv.Response | None":
"""Poll the DB for the operator's response without blocking the """Poll the queue dir for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout.""" proxy event loop. Returns the Response, or None on timeout."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
deadline = loop.time() + self._token_allow_timeout deadline = loop.time() + self._token_allow_timeout
while True: while True:
try: try:
return _sv.read_response(self._supervise_slug, proposal_id) return _sv.read_response(queue_dir, proposal_id)
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until # Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed. # the deadline, then fail closed.
+2 -4
View File
@@ -35,7 +35,6 @@ import re
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .errors import MissingEnvVarError
from .log import die from .log import die
from .manifest import Manifest from .manifest import Manifest
@@ -137,10 +136,9 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv:
host_var = env_entry_interpolated_from(raw) host_var = env_entry_interpolated_from(raw)
host_value = os.environ.get(host_var, "") host_value = os.environ.get(host_var, "")
if not host_value: if not host_value:
raise MissingEnvVarError( die(
host_var,
f"env entry {name} is interpolated from ${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 forwarded[name] = host_value
else: # literal else: # literal
-18
View File
@@ -1,18 +0,0 @@
"""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)
-1
View File
@@ -82,7 +82,6 @@ class GitGatePlan:
class GitGate(ABC): class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare """The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the sidecar's (upstream lift + entrypoint/hook render); the sidecar's
-268
View File
@@ -1,268 +0,0 @@
"""Preflight host-key population for git-gate upstreams (issue #333).
When a git-gate repo entry lacks a `host_key`, this module either:
- headless: dies with a clear config error.
- interactive: fetches the key via ssh-keyscan, prompts the operator to
confirm, and optionally persists it to the bottle config file on disk.
Public entry point: `preflight_host_keys(manifest, headless=..., home_md=...)`.
"""
from __future__ import annotations
import dataclasses
import subprocess
import sys
from pathlib import Path
from typing import cast
from .log import die, info
from .manifest import Manifest
from .yaml_subset import YamlSubsetError, parse_frontmatter, serialize_yaml_subset
# Preferred key types, most secure first.
_KEY_TYPE_PREFERENCE = (
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-rsa",
)
def fetch_host_key(host: str, port: str) -> str:
"""Return an SSH public key for `host`:`port` via ssh-keyscan.
Returns the key in `<type> <base64-data>` format (the host prefix is
stripped so the result can be stored in `host_key` and later formatted
into a known_hosts line by `git_gate_known_hosts_line`).
Prefers ed25519 > ecdsa > rsa; falls back to the first key type
returned if none of the preferred types are present.
Raises `RuntimeError` on subprocess failure, timeout, or no result.
Uses only the Python stdlib (subprocess)."""
args = ["ssh-keyscan"]
if port and port != "22":
args += ["-p", port]
args.append(host)
try:
result = subprocess.run(
args, capture_output=True, text=True, timeout=15, check=False,
)
except OSError as e:
raise RuntimeError(
f"ssh-keyscan: could not launch for {host}:{port}: {e}"
) from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"ssh-keyscan timed out for {host}:{port}") from e
# known_hosts format: "[host]:port type data" or "host type data"
# Strip the host/port prefix; collect "type -> type data" by type.
found: dict[str, str] = {}
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 2)
if len(parts) == 3 and parts[1] not in found:
found[parts[1]] = f"{parts[1]} {parts[2]}"
for preferred in _KEY_TYPE_PREFERENCE:
if preferred in found:
return found[preferred]
if found:
return next(iter(found.values()))
raise RuntimeError(
f"ssh-keyscan returned no host key for {host}:{port}."
+ (f" stderr: {result.stderr.strip()!r}" if result.stderr.strip() else "")
)
# ---------------------------------------------------------------------------
# Frontmatter editing
# ---------------------------------------------------------------------------
def add_host_key_to_frontmatter(file_text: str, repo_name: str, host_key: str) -> str:
"""Return an updated copy of `file_text` with `host_key` set on the
named repo entry in the YAML frontmatter.
Parses the frontmatter into a dict, sets the key, and re-serializes.
Returns the original text unchanged when: the file has no frontmatter,
the git-gate.repos.<repo_name> entry is absent or already has a
host_key, or the frontmatter cannot be parsed."""
try:
fm, body = parse_frontmatter(file_text)
except YamlSubsetError:
return file_text
git_gate = fm.get("git-gate")
if not isinstance(git_gate, dict):
return file_text
repos = git_gate.get("repos")
if not isinstance(repos, dict):
return file_text
repo = repos.get(repo_name)
if not isinstance(repo, dict):
return file_text
if repo.get("host_key"):
return file_text
cast(dict[str, object], repo)["host_key"] = host_key
return f"---\n{serialize_yaml_subset(fm)}---\n{body}"
def find_repo_bottle_file(bottles_dir: Path, repo_name: str) -> Path | None:
"""Return the first `bottles_dir/*.md` that declares `repo_name` without
a `host_key`, without modifying anything. Returns None if not found."""
if not bottles_dir.is_dir():
return None
for path in sorted(bottles_dir.glob("*.md")):
try:
text = path.read_text(encoding="utf-8")
fm, _ = parse_frontmatter(text)
except (OSError, UnicodeDecodeError, YamlSubsetError):
continue
git_gate = fm.get("git-gate")
if not isinstance(git_gate, dict):
continue
repos = git_gate.get("repos")
if not isinstance(repos, dict):
continue
repo = repos.get(repo_name)
if not isinstance(repo, dict) or repo.get("host_key"):
continue
return path
return None
def find_and_update_bottle_file(
bottles_dir: Path, repo_name: str, host_key: str,
) -> bool:
"""Write `host_key` into the bottle file returned by `find_repo_bottle_file`.
Returns True on success, False when no suitable file is found or the
write fails."""
path = find_repo_bottle_file(bottles_dir, repo_name)
if path is None:
return False
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return False
updated = add_host_key_to_frontmatter(text, repo_name, host_key)
if updated == text:
return False
path.write_text(updated, encoding="utf-8")
info(f"wrote host_key for {repo_name!r} to {path}")
return True
# ---------------------------------------------------------------------------
# Interactive prompt helper
# ---------------------------------------------------------------------------
def prompt_tty(message: str) -> str:
"""Write `message` to stderr and read a line from /dev/tty (or stdin)."""
sys.stderr.write(message)
sys.stderr.flush()
try:
with open("/dev/tty", "r", encoding="utf-8") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def preflight_host_keys(
manifest: Manifest,
*,
headless: bool,
home_md: Path | None,
) -> Manifest:
"""Ensure every git-gate repo entry has a `host_key` configured.
For entries whose `KnownHostKey` is empty:
- headless: calls `die()` with a clear message naming the repos.
- interactive: fetches the key via ssh-keyscan, shows it to the
operator, and requests confirmation. If accepted, optionally
persists it to the bottle config file on disk; the key is always
applied in memory for this launch regardless of the persistence
choice. Aborted confirmation calls `die()`.
Returns a (possibly updated) Manifest. If all entries already have
host keys the original manifest is returned unchanged."""
bottle = manifest.bottle
missing = [e for e in bottle.git if not e.KnownHostKey]
if not missing:
return manifest
if headless:
names = ", ".join(repr(e.Name) for e in missing)
die(
f"git-gate: no host_key configured for repo(s) {names}. "
f"Add host_key to each bottle git-gate.repos entry, or run "
f"interactively once to have it fetched and saved automatically."
)
bottles_dir = (home_md / "bottles") if home_md is not None else None
updated_entries = list(bottle.git)
for entry in missing:
host = entry.UpstreamHost
port = entry.UpstreamPort
label = f"git-gate.repos[{entry.Name!r}]"
info(f"{label}: no host_key configured; fetching from {host}:{port}")
try:
key = fetch_host_key(host, port)
except RuntimeError as e:
die(f"git-gate: {label}: {e}")
sys.stderr.write(f"\ngit-gate: host key for {label}:\n {key}\n\n")
confirm = prompt_tty("Is this host key correct? [y/N] ")
if confirm.strip().lower() not in ("y", "yes"):
die(f"git-gate: {label}: host key not confirmed; aborting launch")
if bottles_dir is not None:
target_file = find_repo_bottle_file(bottles_dir, entry.Name)
if target_file is not None:
save = prompt_tty(
f"Save host_key for {entry.Name!r} to {target_file}? [y/N] "
)
if save.strip().lower() in ("y", "yes"):
ok = find_and_update_bottle_file(bottles_dir, entry.Name, key)
if not ok:
sys.stderr.write(
f"git-gate: {label}: could not write to {target_file}; "
f"host_key kept in memory for this session only\n"
)
else:
sys.stderr.write(
f"git-gate: {label}: no bottle config file found for "
f"{entry.Name!r}; host_key kept in memory for this session only\n"
)
idx = next(i for i, e in enumerate(updated_entries) if e.Name == entry.Name)
updated_entries[idx] = dataclasses.replace(entry, KnownHostKey=key)
updated_bottle = dataclasses.replace(bottle, git=tuple(updated_entries))
return dataclasses.replace(manifest, bottle=updated_bottle)
__all__ = [
"fetch_host_key",
"preflight_host_keys",
"add_host_key_to_frontmatter",
"find_repo_bottle_file",
"find_and_update_bottle_file",
"prompt_tty",
]
+4 -7
View File
@@ -13,7 +13,6 @@ import dataclasses
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .errors import MissingEnvVarError
from .log import info from .log import info
from .manifest import ManifestBottle, ManifestGitEntry from .manifest import ManifestBottle, ManifestGitEntry
from .git_gate_render import GitGateUpstream from .git_gate_render import GitGateUpstream
@@ -35,10 +34,9 @@ def _provision_dynamic_key(
pk = entry.Key pk = entry.Key
token = os.environ.get(pk.forge_token_env) token = os.environ.get(pk.forge_token_env)
if token is None: if token is None:
raise MissingEnvVarError( raise RuntimeError(
pk.forge_token_env,
f"git-gate.repos[{entry.Name!r}] key.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}" api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url) provisioner = get_provisioner(pk.provider, token, api_url)
@@ -80,11 +78,10 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
key_id = id_file.read_text().strip() key_id = id_file.read_text().strip()
token = os.environ.get(pk.forge_token_env) token = os.environ.get(pk.forge_token_env)
if token is None: if token is None:
raise MissingEnvVarError( raise RuntimeError(
pk.forge_token_env,
f"git-gate.repos[{entry.Name!r}] key.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;"
f" cannot revoke deploy key {key_id}", f" cannot revoke deploy key {key_id}"
) )
api_url = pk.api_url or f"https://{entry.UpstreamHost}" api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url) provisioner = get_provisioner(pk.provider, token, api_url)
+44 -48
View File
@@ -228,22 +228,19 @@ supervise_gitleaks_allow() {
fi fi
proposal_id=$( proposal_id=$(
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY' GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
import datetime import datetime
import hashlib import hashlib
import json import json
import os import os
import sys import sys
import uuid
from pathlib import Path from pathlib import Path
try:
import supervise as _sv
except ImportError:
from bot_bottle import supervise as _sv
report_path = Path(sys.argv[1]) report_path = Path(sys.argv[1])
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not slug: if not queue_dir or not slug:
sys.exit(2) sys.exit(2)
try: try:
@@ -280,19 +277,31 @@ for i, finding in enumerate(raw, 1):
]) ])
payload = "\n".join(lines).rstrip() + "\n" payload = "\n".join(lines).rstrip() + "\n"
proposal = _sv.Proposal.new( proposal_id = str(uuid.uuid4())
bottle_slug=slug, proposal = {
tool=_sv.TOOL_GITLEAKS_ALLOW, "id": proposal_id,
proposed_file=payload, "bottle_slug": slug,
justification=( "tool": "gitleaks-allow",
"proposed_file": payload,
"justification": (
"git-gate found gitleaks findings hidden by # gitleaks:allow; " "git-gate found gitleaks findings hidden by # gitleaks:allow; "
"approve only for dummy test fixtures or confirmed false positives" "approve only for dummy test fixtures or confirmed false positives"
), ),
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(), "arrival_timestamp": datetime.datetime.now(
now=datetime.datetime.now(datetime.timezone.utc), datetime.timezone.utc
) ).isoformat(),
_sv.write_proposal(proposal) "current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
print(proposal.id) }
queue = Path(queue_dir)
queue.mkdir(parents=True, exist_ok=True)
path = queue / f"{proposal_id}.proposal.json"
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(proposal, f, indent=2)
f.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
print(proposal_id)
PY PY
) )
rc=$? rc=$?
@@ -305,7 +314,8 @@ PY
return 1 return 1
fi fi
slug=${SUPERVISE_BOTTLE_SLUG:-} queue_dir=${SUPERVISE_QUEUE_DIR:-}
response_file="$queue_dir/${proposal_id}.response.json"
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300} timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
case "$timeout" in case "$timeout" in
''|*[!0-9]*) ''|*[!0-9]*)
@@ -317,41 +327,26 @@ PY
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2 echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
waited=0 waited=0
while [ "$waited" -lt "$timeout" ]; do while [ "$waited" -lt "$timeout" ]; do
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' if [ -f "$response_file" ]; then
status=$(python3 - "$response_file" <<'PY'
import json
import sys import sys
try: try:
import supervise as _sv with open(sys.argv[1], encoding="utf-8") as f:
except ImportError: raw = json.load(f)
from bot_bottle import supervise as _sv except (OSError, json.JSONDecodeError):
sys.exit(1)
slug = sys.argv[1] status = raw.get("status")
try: if not isinstance(status, str):
response = _sv.read_response(slug, sys.argv[2]) sys.exit(1)
except FileNotFoundError: print(status)
sys.exit(2)
print(response.status)
PY PY
) ) || status=""
rc=$?
if [ "$rc" -eq 2 ]; then
status=""
elif [ "$rc" -ne 0 ]; then
status="invalid"
fi
if [ -n "$status" ]; then
case "$status" in case "$status" in
approved|modified) approved|modified)
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' || true mkdir -p "$queue_dir/processed"
import sys 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
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 echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
return 0 return 0
;; ;;
@@ -504,3 +499,4 @@ if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
fi fi
exit 0 exit 0
""" """
+10 -4
View File
@@ -25,8 +25,9 @@ class ManifestAgentProvider:
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
so the Claude Code CLI starts. so the Claude Code CLI starts.
`forward_host_credentials` forwards the host Codex auth token into `forward_host_credentials` forwards the host provider auth token into
the egress sidecar (Codex only). the egress sidecar (Codex and Claude). For Codex this reads
`~/.codex/auth.json`; for Claude it reads `~/.claude.json`.
""" """
template: str = "claude" template: str = "claude"
@@ -92,10 +93,15 @@ class ManifestAgentProvider:
f"is only supported for built-in templates " f"is only supported for built-in templates "
f"({', '.join(sorted(PROVIDER_TEMPLATES))})" f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
) )
if forward_host_credentials and template != "codex": if forward_host_credentials and template not in {"codex", "claude"}:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials " f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"is currently only supported for template 'codex'" "is only supported for templates 'codex' and 'claude'"
)
if forward_host_credentials and auth_token:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"and auth_token both set; use one or the other"
) )
settings = _parse_provider_settings(bottle_name, template, d.get("settings")) settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
return cls( return cls(
-37
View File
@@ -1,37 +0,0 @@
"""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"]
+8
View File
@@ -0,0 +1,8 @@
"""bot-bottle-orchestrator: forge-native orchestration for bot-bottle.
The package is stdlib-only. The core (events, targeting, lifecycle,
watchdog, sidecar, webhook) depends on its collaborators a forge, a
state store, a bottle runner through duck-typed interfaces, so it runs
and tests without bot-bottle installed. `bootstrap` is the single module
that imports `bot_bottle` and wires the concrete implementations.
"""
+51
View File
@@ -0,0 +1,51 @@
"""CLI entry point: `python -m bot_bottle.orchestrator <command>`.
Commands:
run start the webhook server + watchdog + done-signal relay
status print the tracked runs (issue -> slug, status)
"""
from __future__ import annotations
import argparse
import sys
from .config import Config
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m bot_bottle.orchestrator")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("run", help="start the webhook server, watchdog, and relay")
sub.add_parser("status", help="list tracked runs")
args = parser.parse_args(argv)
config = Config.from_env()
if args.command == "run":
from . import bootstrap # pylint: disable=import-outside-toplevel
print(
f"orchestrator listening on "
f"http://{config.webhook_host}:{config.webhook_port}/webhook",
file=sys.stderr,
)
bootstrap.run(config)
return 0
if args.command == "status":
from .bootstrap import ( # pylint: disable=import-outside-toplevel
BotBottleStateStore,
)
store = BotBottleStateStore(config.db_path)
for r in store.all():
pr = f"PR#{r.pr_number}" if r.pr_number else "-"
print(f"{r.owner}/{r.repo}#{r.issue_number}\t{r.slug}\t{r.status}\t{pr}")
return 0
return 2
if __name__ == "__main__":
sys.exit(main())
+155
View File
@@ -0,0 +1,155 @@
"""Wire the concrete bot-bottle implementations into the core.
This is the ONLY module that imports from `bot_bottle.contrib`. It adapts
`SqliteForgeStateStore` to our `StateStore`, builds `GiteaForge`s (and
scope-wrapped forges for sidecars), constructs the `Orchestrator`, and
runs the webhook server + watchdog + done-signal relay.
Imports are direct (no lazy loading) because the orchestrator is now part
of the same package installation.
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
from typing import Any
from ..contrib.forge.base import ScopedForge
from ..contrib.gitea.client import GiteaClient, GiteaForge
from ..contrib.gitea.forge_state import ForgeState, SqliteForgeStateStore
from .config import Config
from .lifecycle import Orchestrator
from .model import RunRecord
from .runner import SubprocessBottleRunner
from .sidecar import ForgeSidecar, OpLog, drain_done_events
from .watchdog import Watchdog
from .webhook import WebhookServer
_RELAY_TICK_SECS = 2.0
def _token() -> str:
tok = os.environ.get("GITEA_TOKEN") or os.environ.get("FORGE_GITEA_TOKEN")
if not tok:
raise RuntimeError("set GITEA_TOKEN (or FORGE_GITEA_TOKEN)")
return tok
class BotBottleStateStore:
"""Adapts `SqliteForgeStateStore` to our `StateStore`, translating
`RunRecord` <-> `ForgeState` field-for-field."""
def __init__(self, db_path: Path | None) -> None:
self._inner = SqliteForgeStateStore(db_path)
def upsert(self, record: RunRecord) -> None:
self._inner.upsert(_to_forge_state(record))
def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None:
state = self._inner.get(owner, repo, issue_number)
return _to_record(state) if state is not None else None
def delete(self, owner: str, repo: str, issue_number: int) -> None:
self._inner.delete(owner, repo, issue_number)
def all(self) -> list[RunRecord]:
return [_to_record(s) for s in self._inner.all()]
def _to_forge_state(r: RunRecord) -> ForgeState:
return ForgeState(
owner=r.owner, repo=r.repo, issue_number=r.issue_number, slug=r.slug,
agent_name=r.agent_name, bottle_names=list(r.bottle_names),
backend_name=r.backend_name, agent_git_user=r.agent_git_user,
pr_number=r.pr_number, status=r.status, last_checkin_at=r.last_checkin_at,
)
def _to_record(s: ForgeState) -> RunRecord:
return RunRecord(
owner=s.owner, repo=s.repo, issue_number=s.issue_number, slug=s.slug,
agent_name=s.agent_name, bottle_names=list(s.bottle_names),
backend_name=s.backend_name, agent_git_user=s.agent_git_user,
pr_number=s.pr_number, status=s.status, last_checkin_at=s.last_checkin_at,
)
def make_forge(config: Config, owner: str, repo: str) -> Any:
"""A `GiteaForge` bound to one repo."""
client = GiteaClient(
api_url=config.gitea_api, owner=owner, repo=repo, token=_token()
)
return GiteaForge(client)
def make_sidecar(
config: Config, owner: str, repo: str, issue_number: int, assigned_prs: list[int]
) -> ForgeSidecar:
"""A scope-enforced sidecar for one run (read-anywhere / write-scoped)."""
scoped = ScopedForge(
make_forge(config, owner, repo),
assigned_issue=issue_number,
assigned_prs=assigned_prs,
)
op_log = OpLog(config.queue_dir / f"{owner}-{repo}-{issue_number}.oplog.jsonl")
return ForgeSidecar(
forge=scoped,
op_log=op_log,
queue_dir=config.queue_dir,
run_key=(owner, repo, issue_number),
)
def build(config: Config) -> tuple[WebhookServer, Watchdog, Orchestrator]:
store = BotBottleStateStore(config.db_path)
runner = SubprocessBottleRunner(cli=config.bot_bottle_cli, base_env=dict(os.environ))
membership_forge = make_forge(config, "_", "_")
orchestrator = Orchestrator(
forge=membership_forge,
store=store,
runner=runner,
org=config.forge_org,
gitea_api=config.gitea_api,
forge_env_base={
"GITEA_TOKEN": _token(),
"FORGE_QUEUE_DIR": str(config.queue_dir),
"FORGE_SIDECAR_SOCKET": str(config.sidecar_socket),
},
)
watchdog = Watchdog(
store=store, runner=runner, timeout_secs=config.watchdog_timeout_secs
)
server = WebhookServer(
(config.webhook_host, config.webhook_port),
orchestrator=orchestrator,
store=store,
)
return server, watchdog, orchestrator
def _relay_loop(config: Config, orchestrator: Orchestrator, stop: threading.Event) -> None:
while not stop.wait(_RELAY_TICK_SECS):
for ev in drain_done_events(config.queue_dir):
orchestrator.on_done_signal(
ev["owner"], ev["repo"], int(ev["issue_number"]),
str(ev.get("status", "")), str(ev.get("summary", "")),
)
def run(config: Config) -> None:
"""Blocking run: webhook server + watchdog + done-signal relay."""
server, watchdog, orchestrator = build(config)
watchdog.start()
stop = threading.Event()
relay = threading.Thread(
target=_relay_loop, args=(config, orchestrator, stop), daemon=True
)
relay.start()
try:
server.serve_forever()
finally:
stop.set()
watchdog.stop()
server.server_close()
+52
View File
@@ -0,0 +1,52 @@
"""Configuration, loaded from the environment (stdlib `os` only).
Everything the orchestrator needs to run is an env var so a deploy is a
process with an environment, no config file to manage. `FORGE_*` names
match the bot-bottle forge-native PRD.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
# The label that marks an issue as agent-targeted: `bot-bottle:<agent>`.
LABEL_PREFIX = "bot-bottle:"
# Optional bottle override: `bot-bottle-bottle:<name>`.
BOTTLE_LABEL_PREFIX = "bot-bottle-bottle:"
@dataclass(frozen=True)
class Config:
"""Resolved orchestrator configuration."""
forge_org: str
gitea_api: str
watchdog_timeout_secs: int
webhook_host: str
webhook_port: int
bot_bottle_cli: str
queue_dir: Path
sidecar_socket: Path
db_path: Path | None
@staticmethod
def from_env(env: dict[str, str] | None = None) -> "Config":
e = os.environ if env is None else env
home = Path(e.get("HOME", str(Path.home())))
default_root = home / ".bot-bottle"
db = e.get("FORGE_DB_PATH")
return Config(
forge_org=e.get("FORGE_ORG", "bot-bottle"),
gitea_api=e.get("FORGE_GITEA_API", ""),
watchdog_timeout_secs=int(e.get("FORGE_WATCHDOG_TIMEOUT", "1800")),
webhook_host=e.get("FORGE_WEBHOOK_HOST", "127.0.0.1"),
webhook_port=int(e.get("FORGE_WEBHOOK_PORT", "8477")),
bot_bottle_cli=e.get("BOT_BOTTLE_CLI", "cli.py"),
queue_dir=Path(e.get("FORGE_QUEUE_DIR", str(default_root / "forge-queue"))),
sidecar_socket=Path(
e.get("FORGE_SIDECAR_SOCKET", str(default_root / "forge-sidecar.sock"))
),
db_path=Path(db) if db else None,
)
+85
View File
@@ -0,0 +1,85 @@
"""Parse Gitea webhook payloads into typed `ForgeEvent`s.
Only the fields the orchestrator acts on are extracted; unknown payloads
and event types return None so the webhook layer can ignore them.
Gitea sends the event kind in the `X-Gitea-Event` header and the payload
as JSON. The relevant kinds:
- `issues` with `action == "assigned"` -> IssueAssigned
- `issue_comment` with `action == "created"` -> CommentCreated
- `pull_request` with `action == "closed"` -> PullRequestClosed
"""
from __future__ import annotations
from typing import Any
from .model import CommentCreated, ForgeEvent, IssueAssigned, PullRequestClosed
def _repo_owner(payload: dict[str, Any]) -> tuple[str, str]:
repo = payload.get("repository") or {}
owner = (repo.get("owner") or {}).get("login", "")
return str(owner), str(repo.get("name", ""))
def parse_event(event_kind: str, payload: dict[str, Any]) -> ForgeEvent | None:
"""Map (X-Gitea-Event, payload) to a `ForgeEvent`, or None to ignore."""
if event_kind == "issues":
return _parse_issue(payload)
if event_kind == "issue_comment":
return _parse_comment(payload)
if event_kind == "pull_request":
return _parse_pull_request(payload)
return None
def _parse_issue(payload: dict[str, Any]) -> IssueAssigned | None:
if payload.get("action") != "assigned":
return None
owner, repo = _repo_owner(payload)
issue = payload.get("issue") or {}
assignees = tuple(
str(a.get("login", "")) for a in (issue.get("assignees") or [])
)
labels = tuple(str(l.get("name", "")) for l in (issue.get("labels") or []))
return IssueAssigned(
owner=owner,
repo=repo,
issue_number=int(issue.get("number", 0)),
title=str(issue.get("title", "")),
body=str(issue.get("body", "") or ""),
assignees=assignees,
labels=labels,
)
def _parse_comment(payload: dict[str, Any]) -> CommentCreated | None:
if payload.get("action") != "created":
return None
owner, repo = _repo_owner(payload)
issue = payload.get("issue") or {}
comment = payload.get("comment") or {}
return CommentCreated(
owner=owner,
repo=repo,
issue_number=int(issue.get("number", 0)),
comment_id=int(comment.get("id", 0)),
author=str((comment.get("user") or {}).get("login", "")),
body=str(comment.get("body", "") or ""),
is_pull=bool(issue.get("pull_request")),
)
def _parse_pull_request(payload: dict[str, Any]) -> PullRequestClosed | None:
if payload.get("action") != "closed":
return None
owner, repo = _repo_owner(payload)
pr = payload.get("pull_request") or {}
return PullRequestClosed(
owner=owner,
repo=repo,
pr_number=int(pr.get("number", 0)),
merged=bool(pr.get("merged", False)),
)
+180
View File
@@ -0,0 +1,180 @@
"""The orchestration lifecycle: forge events -> bottle transitions.
`Orchestrator.handle(event)` is the single entry point the webhook layer
calls. `on_done_signal(...)` is called by the sidecar relay when an agent
signals completion. All collaborators (forge, store, runner) are
injected and duck-typed; `now` and `label_for` are injectable for tests.
Transitions:
IssueAssigned (targeted, new) -> start bottle, record = running
signal_done (running) -> freeze bottle, record = frozen
CommentCreated (frozen) -> resume bottle, record = running
PullRequestClosed (tracked) -> destroy bottle, record removed
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from .model import (
STATUS_DESTROYED,
STATUS_FROZEN,
STATUS_RUNNING,
CommentCreated,
ForgeEvent,
IssueAssigned,
PullRequestClosed,
RunRecord,
)
from .runner import BottleRunner
from .store import StateStore
from .targeting import Membership, Target, resolve_target
def _iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _default_label(agent: str, event: IssueAssigned) -> str:
# Embed the issue identity so slugs are unique per issue and never
# get renamed on collision.
return f"{agent}-{event.owner}-{event.repo}-{event.issue_number}"
class Orchestrator:
def __init__(
self,
*,
forge: Membership,
store: StateStore,
runner: BottleRunner,
org: str,
gitea_api: str = "",
forge_env_base: dict[str, str] | None = None,
now: Callable[[], str] = _iso_now,
label_for: Callable[[str, IssueAssigned], str] = _default_label,
) -> None:
self._forge = forge
self._store = store
self._runner = runner
self._org = org
self._gitea_api = gitea_api
self._forge_env_base = forge_env_base or {}
self._now = now
self._label_for = label_for
# --- entry points ------------------------------------------------------
def handle(self, event: ForgeEvent) -> None:
if isinstance(event, IssueAssigned):
self._on_issue_assigned(event)
elif isinstance(event, CommentCreated):
self._on_comment(event)
else:
self._on_pr_closed(event)
def on_done_signal( # pylint: disable=unused-argument
self, owner: str, repo: str, issue_number: int, status: str, summary: str
) -> None:
"""Sidecar relay: an agent signalled completion. Freeze the bottle.
`status`/`summary` are recorded by provenance (via the op log), not
acted on here."""
record = self._store.get(owner, repo, issue_number)
if record is None or record.status != STATUS_RUNNING:
return
self._runner.freeze(record.slug)
record.status = STATUS_FROZEN
record.last_checkin_at = self._now()
self._store.upsert(record)
def link_pr(self, owner: str, repo: str, issue_number: int, pr_number: int) -> None:
"""Record the PR a tracked issue produced, so PR comments and the
PR-close event route back to this record."""
record = self._store.get(owner, repo, issue_number)
if record is not None:
record.pr_number = pr_number
self._store.upsert(record)
# --- handlers ----------------------------------------------------------
def _on_issue_assigned(self, event: IssueAssigned) -> None:
target = resolve_target(event, self._forge, self._org)
if target is None:
return
# Idempotent: a webhook redelivery must not launch a second bottle.
if self._store.get(event.owner, event.repo, event.issue_number) is not None:
return
self._launch(event, target)
def _launch(self, event: IssueAssigned, target: Target) -> None:
label = self._label_for(target.agent_name, event)
bottles = [target.bottle_override] if target.bottle_override else []
result = self._runner.start(
agent=target.agent_name,
bottles=bottles,
label=label,
prompt=event.body,
forge_env=self._forge_env(event.owner, event.repo, event.issue_number),
)
self._store.upsert(
RunRecord(
owner=event.owner,
repo=event.repo,
issue_number=event.issue_number,
slug=result.slug,
agent_name=target.agent_name,
bottle_names=bottles,
status=STATUS_RUNNING,
last_checkin_at=self._now(),
)
)
def _on_comment(self, event: CommentCreated) -> None:
record = self._route_comment(event)
if record is None or record.status != STATUS_FROZEN:
return
# Echo-loop guard: ignore the agent's own comments.
if record.agent_git_user and event.author == record.agent_git_user:
return
self._runner.resume(record.slug, event.body)
record.status = STATUS_RUNNING
record.last_checkin_at = self._now()
self._store.upsert(record)
def _route_comment(self, event: CommentCreated) -> RunRecord | None:
# A comment on the issue routes by issue number; a comment on a PR
# routes by the recorded pr_number.
direct = self._store.get(event.owner, event.repo, event.issue_number)
if direct is not None:
return direct
if event.is_pull:
return self._find_by_pr(event.owner, event.repo, event.issue_number)
return None
def _on_pr_closed(self, event: PullRequestClosed) -> None:
record = self._find_by_pr(event.owner, event.repo, event.pr_number)
if record is None:
return
self._runner.destroy(record.slug)
record.status = STATUS_DESTROYED
self._store.delete(record.owner, record.repo, record.issue_number)
def _find_by_pr(self, owner: str, repo: str, pr_number: int) -> RunRecord | None:
for record in self._store.all():
if (
record.owner == owner
and record.repo == repo
and record.pr_number == pr_number
):
return record
return None
def _forge_env(self, owner: str, repo: str, issue_number: int) -> dict[str, str]:
env = dict(self._forge_env_base)
if self._gitea_api:
env["FORGE_GITEA_API"] = self._gitea_api
env["FORGE_OWNER"] = owner
env["FORGE_REPO"] = repo
env["FORGE_ISSUE_NUMBER"] = str(issue_number)
return env
+108
View File
@@ -0,0 +1,108 @@
"""Domain model: run records, forge events, provenance.
These are the orchestrator's own dataclasses. `RunRecord` mirrors
bot-bottle's `ForgeState` field-for-field so the bootstrap adapter can
translate between them with no loss; keeping our own copy is what lets
the core stay import-free of bot-bottle.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Run 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 RunRecord:
"""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 = ""
# --- Forge events (parsed webhook payloads) --------------------------------
@dataclass(frozen=True)
class IssueAssigned:
"""An issue gained an assignee — the trigger to consider a launch."""
owner: str
repo: str
issue_number: int
title: str
body: str
assignees: tuple[str, ...]
labels: tuple[str, ...]
@dataclass(frozen=True)
class CommentCreated:
"""A comment was posted on an issue or PR — a rehydrate trigger."""
owner: str
repo: str
issue_number: int
comment_id: int
author: str
body: str
is_pull: bool
@dataclass(frozen=True)
class PullRequestClosed:
"""A PR closed (merged or not) — the teardown trigger."""
owner: str
repo: str
pr_number: int
merged: bool
# Union of everything the webhook layer can emit.
ForgeEvent = IssueAssigned | CommentCreated | PullRequestClosed
# --- Provenance ------------------------------------------------------------
@dataclass(frozen=True)
class ForgeOp:
"""One semantic forge operation the sidecar recorded."""
at: str # ISO timestamp
op: str # e.g. "post_comment", "read_pr", "signal_done"
target: int | None
detail: str
@dataclass(frozen=True)
class Provenance:
"""The audit record for one run, served by the provenance API. Never
posted into the forge."""
slug: str
owner: str
repo: str
issue_number: int
agent_name: str
bottle_names: tuple[str, ...]
started_at: str
finished_at: str
exit_code: int | None
watchdog_fired: bool
ops: tuple[ForgeOp, ...]
+71
View File
@@ -0,0 +1,71 @@
"""Provenance assembly + serialization.
Provenance is the run's audit record: the `RunRecord` metadata plus the
sidecar's semantic operation log. It is exposed through the provenance
API (see `webhook.ProvenanceHandler`) and deliberately never posted back
into the forge a mutable PR comment is not an audit record.
This module only assembles and serializes; retention/signing of the
record is a control-plane concern out of scope here.
"""
from __future__ import annotations
from typing import Any
from .model import ForgeOp, Provenance, RunRecord
def ops_from_log(entries: list[dict[str, Any]]) -> tuple[ForgeOp, ...]:
return tuple(
ForgeOp(
at=str(e.get("at", "")),
op=str(e.get("op", "")),
target=e.get("target"),
detail=str(e.get("detail", "")),
)
for e in entries
)
def build_provenance(
record: RunRecord,
*,
ops: tuple[ForgeOp, ...],
started_at: str,
finished_at: str,
exit_code: int | None,
watchdog_fired: bool,
) -> Provenance:
return Provenance(
slug=record.slug,
owner=record.owner,
repo=record.repo,
issue_number=record.issue_number,
agent_name=record.agent_name,
bottle_names=tuple(record.bottle_names),
started_at=started_at,
finished_at=finished_at,
exit_code=exit_code,
watchdog_fired=watchdog_fired,
ops=ops,
)
def provenance_to_dict(p: Provenance) -> dict[str, Any]:
return {
"slug": p.slug,
"owner": p.owner,
"repo": p.repo,
"issue_number": p.issue_number,
"agent": p.agent_name,
"bottles": list(p.bottle_names),
"started_at": p.started_at,
"finished_at": p.finished_at,
"exit_code": p.exit_code,
"watchdog_fired": p.watchdog_fired,
"ops": [
{"at": o.at, "op": o.op, "target": o.target, "detail": o.detail}
for o in p.ops
],
}
+118
View File
@@ -0,0 +1,118 @@
"""Bottle runner: drive the bot-bottle CLI to manage a bottle's life.
`BottleRunner` is the interface the lifecycle depends on;
`SubprocessBottleRunner` shells out to the bot-bottle `cli.py`
(`start --headless`, `commit`, `resume --headless`). The subprocess
callable is injectable so tests never spawn a process.
The slug is derived from the label via `slugify`, matching bot-bottle's
container-slug rule; the orchestrator picks labels that embed the issue
identity so slugs are unique and collisions never rename them.
"""
from __future__ import annotations
import re
import subprocess
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class RunResult:
slug: str
exit_code: int
class BottleRunner(Protocol):
def start(
self,
*,
agent: str,
bottles: Sequence[str],
label: str,
prompt: str,
forge_env: dict[str, str],
) -> RunResult: ...
def freeze(self, slug: str) -> int: ...
def resume(self, slug: str, prompt: str) -> RunResult: ...
def destroy(self, slug: str) -> int: ...
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def slugify(label: str) -> str:
"""Lowercase, collapse non-alphanumerics to single hyphens, strip
leading/trailing hyphens matches bot-bottle's slug rule."""
return _SLUG_RE.sub("-", label.lower()).strip("-")
# A subprocess.run-shaped callable, injectable for tests.
RunFn = Callable[[Sequence[str], dict[str, str]], int]
def _default_run(argv: Sequence[str], env: dict[str, str]) -> int:
return subprocess.run(list(argv), env=env, check=False).returncode
class SubprocessBottleRunner:
"""Shells the bot-bottle CLI. `cli` is the path to `cli.py`; `python`
is the interpreter to run it with; `base_env` is the environment the
child inherits (the orchestrator's, minus per-run additions)."""
def __init__(
self,
*,
cli: str,
base_env: dict[str, str],
python: str = sys.executable,
run: RunFn = _default_run,
) -> None:
self._cli = cli
self._python = python
self._base_env = base_env
self._run = run
def _argv(self, *args: str) -> list[str]:
return [self._python, self._cli, *args]
def start(
self,
*,
agent: str,
bottles: Sequence[str],
label: str,
prompt: str,
forge_env: dict[str, str],
) -> RunResult:
argv = self._argv(
"start", agent, "--headless", "--label", label, "--prompt", prompt
)
for bottle in bottles:
argv += ["--bottle", bottle]
code = self._run(argv, {**self._base_env, **forge_env})
return RunResult(slug=slugify(label), exit_code=code)
def freeze(self, slug: str) -> int:
# bot-bottle's `commit` snapshots a running bottle's state.
return self._run(self._argv("commit", slug), self._base_env)
def resume(self, slug: str, prompt: str) -> RunResult:
code = self._run(
self._argv("resume", slug, "--headless", "--prompt", prompt),
self._base_env,
)
return RunResult(slug=slug, exit_code=code)
def destroy(self, slug: str) -> int:
# NOTE: bot-bottle `cleanup` currently targets all bottles; a
# per-slug teardown command is a known integration follow-up
# (tracked in docs/JOURNAL.md). Kept behind this method so the
# call site does not change when that lands.
return self._run(self._argv("cleanup", slug), self._base_env)
+171
View File
@@ -0,0 +1,171 @@
"""Forge sidecar: the agent's only door to the forge.
The agent calls the sidecar over a line-delimited JSON-RPC AF_UNIX
socket; the sidecar dispatches to an injected `forge` (already
scope-wrapped by bootstrap) and holds the token, so the agent never sees
a credential or a forge endpoint. Every call is appended to a semantic
operation log (the provenance raw material). `signal_done` additionally
drops an event file in the queue dir the orchestrator drains.
`dispatch` is pure and testable; `serve` wraps it in a socket server.
"""
from __future__ import annotations
import dataclasses
import json
import socketserver
import uuid
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
_READ_METHODS = {"read_issue", "read_pr", "read_comments"}
_WRITE_METHODS = {"post_comment", "update_description"}
def _iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _jsonable(value: Any) -> Any:
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return dataclasses.asdict(value)
if isinstance(value, list):
return [_jsonable(v) for v in value]
return value
class OpLog:
"""Append-only JSONL log of semantic forge operations."""
def __init__(self, path: Path, *, now: Callable[[], str] = _iso_now) -> None:
self._path = path
self._now = now
path.parent.mkdir(parents=True, exist_ok=True)
def record(self, op: str, target: int | None, detail: str) -> None:
entry = {"at": self._now(), "op": op, "target": target, "detail": detail}
with self._path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry) + "\n")
def read(self) -> list[dict[str, Any]]:
if not self._path.exists():
return []
return [
json.loads(line)
for line in self._path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def write_done_event(queue_dir: Path, event: dict[str, Any]) -> Path:
"""Atomically drop a done-signal event file in the queue dir."""
queue_dir.mkdir(parents=True, exist_ok=True)
path = queue_dir / f"done-{uuid.uuid4().hex}.json"
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(event), encoding="utf-8")
tmp.replace(path)
return path
def drain_done_events(queue_dir: Path) -> list[dict[str, Any]]:
"""Read and remove every queued done-signal event."""
if not queue_dir.is_dir():
return []
events: list[dict[str, Any]] = []
for path in sorted(queue_dir.glob("done-*.json")):
try:
events.append(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
finally:
path.unlink(missing_ok=True)
return events
class ForgeSidecar:
"""Dispatches sidecar protocol calls to the forge, logging each and
relaying `signal_done` to the queue dir. `run_key` is the
(owner, repo, issue_number) the run is bound to."""
def __init__(
self,
*,
forge: object,
op_log: OpLog,
queue_dir: Path,
run_key: tuple[str, str, int],
) -> None:
self._forge = forge
self._log = op_log
self._queue_dir = queue_dir
self._owner, self._repo, self._issue = run_key
def dispatch(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
try:
result = self._invoke(method, params)
except Exception as exc: # noqa: BLE001 — surface as JSON-RPC error
self._log.record(method, params.get("number"), f"error: {exc}")
return {"ok": False, "error": str(exc)}
return {"ok": True, "result": result}
def _invoke(self, method: str, params: dict[str, Any]) -> Any:
if method in _READ_METHODS:
number = int(params["number"])
result = getattr(self._forge, method)(number)
self._log.record(method, number, "ok")
return _jsonable(result)
if method in _WRITE_METHODS:
number = int(params["number"])
getattr(self._forge, method)(number, params["body"])
self._log.record(method, number, "ok")
return None
if method == "signal_done":
status = str(params.get("status", ""))
summary = str(params.get("summary", ""))
self._log.record("signal_done", None, f"{status}: {summary}")
write_done_event(
self._queue_dir,
{
"owner": self._owner,
"repo": self._repo,
"issue_number": self._issue,
"status": status,
"summary": summary,
},
)
return None
raise ValueError(f"unknown method: {method}")
class _Handler(socketserver.StreamRequestHandler):
def handle(self) -> None:
line = self.rfile.readline()
if not line:
return
try:
req = json.loads(line)
except ValueError:
self.wfile.write(b'{"ok": false, "error": "invalid json"}\n')
return
resp = self.server.sidecar.dispatch( # type: ignore[attr-defined]
str(req.get("method", "")), dict(req.get("params", {}))
)
self.wfile.write((json.dumps(resp) + "\n").encode())
class _Server(socketserver.ThreadingUnixStreamServer):
def __init__(self, socket_path: str, sidecar: ForgeSidecar) -> None:
super().__init__(socket_path, _Handler)
self.sidecar = sidecar
def serve(sidecar: ForgeSidecar, socket_path: Path) -> _Server:
"""Bind a threaded AF_UNIX server for `sidecar`. Caller runs
`serve_forever()` (or `handle_request()` in tests) and closes it."""
if socket_path.exists():
socket_path.unlink()
socket_path.parent.mkdir(parents=True, exist_ok=True)
return _Server(str(socket_path), sidecar)
+48
View File
@@ -0,0 +1,48 @@
"""State store interface + an in-memory implementation.
The orchestrator persists one `RunRecord` per forge-targeted issue. At
runtime `bootstrap` supplies an adapter over bot-bottle's
`SqliteForgeStateStore`; the in-memory store here backs tests and a
`--no-bot-bottle` dry mode.
"""
from __future__ import annotations
from typing import Protocol
from .model import RunRecord
class StateStore(Protocol):
"""Thin CRUD surface. Mirrors bot-bottle's `ForgeStateStore` so the
bootstrap adapter is a straight pass-through."""
def upsert(self, record: RunRecord) -> None: ...
def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None: ...
def delete(self, owner: str, repo: str, issue_number: int) -> None: ...
def all(self) -> list[RunRecord]: ...
class InMemoryStateStore:
"""Dict-backed `StateStore`, keyed by (owner, repo, issue_number)."""
def __init__(self) -> None:
self._by_key: dict[tuple[str, str, int], RunRecord] = {}
def upsert(self, record: RunRecord) -> None:
self._by_key[(record.owner, record.repo, record.issue_number)] = record
def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None:
return self._by_key.get((owner, repo, issue_number))
def delete(self, owner: str, repo: str, issue_number: int) -> None:
self._by_key.pop((owner, repo, issue_number), None)
def all(self) -> list[RunRecord]:
return sorted(
self._by_key.values(),
key=lambda r: (r.owner, r.repo, r.issue_number),
)
+51
View File
@@ -0,0 +1,51 @@
"""Decide whether an assigned issue is agent-targeted, and for whom.
An issue is forge-targeted when BOTH hold:
- it carries a `bot-bottle:<agent>` label naming the agent, and
- at least one assignee is a member of the configured org.
An optional `bot-bottle-bottle:<name>` label overrides bottle selection.
The forge is duck-typed: any object with `is_org_member(org, user)`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from .config import BOTTLE_LABEL_PREFIX, LABEL_PREFIX
from .model import IssueAssigned
class Membership(Protocol):
def is_org_member(self, org: str, username: str) -> bool: ...
@dataclass(frozen=True)
class Target:
agent_name: str
bottle_override: str | None
def parse_labels(labels: tuple[str, ...]) -> tuple[str | None, str | None]:
"""Return (agent_name, bottle_override) parsed from labels."""
agent: str | None = None
bottle: str | None = None
for label in labels:
if label.startswith(BOTTLE_LABEL_PREFIX):
bottle = label[len(BOTTLE_LABEL_PREFIX):] or None
elif label.startswith(LABEL_PREFIX):
agent = label[len(LABEL_PREFIX):] or None
return agent, bottle
def resolve_target(
event: IssueAssigned, forge: Membership, org: str
) -> Target | None:
"""Return the `Target` for a forge-targeted issue, or None to ignore."""
agent, bottle = parse_labels(event.labels)
if not agent:
return None
if not any(forge.is_org_member(org, a) for a in event.assignees):
return None
return Target(agent_name=agent, bottle_override=bottle)
+68
View File
@@ -0,0 +1,68 @@
"""Watchdog: freeze runs whose agent exited without signalling done.
`sweep(now)` is the pure, testable core: any `running` record whose
`last_checkin_at` is older than the timeout is frozen as
done-without-self-report and returned so provenance can flag it.
`Watchdog.start()` runs `sweep` on a daemon thread once a minute.
"""
from __future__ import annotations
import threading
from datetime import datetime, timedelta
from .model import STATUS_FROZEN, STATUS_RUNNING, RunRecord
from .runner import BottleRunner
from .store import StateStore
_TICK_SECS = 60.0
def _parse(ts: str) -> datetime | None:
try:
return datetime.fromisoformat(ts)
except (ValueError, TypeError):
return None
class Watchdog:
def __init__(
self,
*,
store: StateStore,
runner: BottleRunner,
timeout_secs: int,
) -> None:
self._store = store
self._runner = runner
self._timeout = timedelta(seconds=timeout_secs)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def sweep(self, now: datetime) -> list[RunRecord]:
"""Freeze stale running records. Returns the ones fired."""
fired: list[RunRecord] = []
for record in self._store.all():
if record.status != STATUS_RUNNING:
continue
checkin = _parse(record.last_checkin_at)
if checkin is None or now - checkin <= self._timeout:
continue
self._runner.freeze(record.slug)
record.status = STATUS_FROZEN
self._store.upsert(record)
fired.append(record)
return fired
def start(self) -> None:
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=_TICK_SECS)
def _loop(self) -> None:
while not self._stop.wait(_TICK_SECS):
self.sweep(datetime.now().astimezone())
+123
View File
@@ -0,0 +1,123 @@
"""HTTP surface: the Gitea webhook receiver and the provenance API.
`POST /webhook` a Gitea event; parsed and dispatched to the orchestrator.
`GET /healthz` liveness.
`GET /provenance?owner=&repo=&issue=` the run's audit record (never
posted to the forge).
Webhook signature verification is optional: set a secret and the handler
rejects bodies whose `X-Gitea-Signature` HMAC-SHA256 does not match.
"""
from __future__ import annotations
import hmac
import json
from collections.abc import Callable
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from .events import parse_event
from .lifecycle import Orchestrator
from .provenance import build_provenance, ops_from_log, provenance_to_dict
from .store import StateStore
# (record) -> that run's op-log entries, injected by bootstrap.
OpLogReader = Callable[[Any], list[dict[str, Any]]]
class WebhookServer(ThreadingHTTPServer):
def __init__(
self,
address: tuple[str, int],
*,
orchestrator: Orchestrator,
store: StateStore,
secret: bytes | None = None,
op_log_reader: OpLogReader | None = None,
) -> None:
super().__init__(address, _Handler)
self.orchestrator = orchestrator
self.store = store
self.secret = secret
self.op_log_reader = op_log_reader
def verify_signature(secret: bytes, body: bytes, signature: str) -> bool:
expected = hmac.new(secret, body, sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
class _Handler(BaseHTTPRequestHandler):
server: WebhookServer # type: ignore[assignment]
def log_message( # pylint: disable=redefined-builtin
self, format: str, *args: Any
) -> None: # quiet by default
pass
def _send(self, code: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None: # noqa: N802 # pylint: disable=invalid-name
if urlparse(self.path).path != "/webhook":
self._send(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
if self.server.secret is not None:
sig = self.headers.get("X-Gitea-Signature", "")
if not verify_signature(self.server.secret, body, sig):
self._send(401, {"error": "bad signature"})
return
try:
payload = json.loads(body)
except ValueError:
self._send(400, {"error": "invalid json"})
return
kind = self.headers.get("X-Gitea-Event", "")
event = parse_event(kind, payload)
if event is not None:
self.server.orchestrator.handle(event)
self._send(200, {"ok": True, "handled": event is not None})
def do_GET(self) -> None: # noqa: N802 # pylint: disable=invalid-name
parsed = urlparse(self.path)
if parsed.path == "/healthz":
self._send(200, {"ok": True})
return
if parsed.path == "/provenance":
self._provenance(parse_qs(parsed.query))
return
self._send(404, {"error": "not found"})
def _provenance(self, query: dict[str, list[str]]) -> None:
try:
owner = query["owner"][0]
repo = query["repo"][0]
issue = int(query["issue"][0])
except (KeyError, IndexError, ValueError):
self._send(400, {"error": "owner, repo, issue required"})
return
record = self.server.store.get(owner, repo, issue)
if record is None:
self._send(404, {"error": "no such run"})
return
reader = self.server.op_log_reader
ops = ops_from_log(reader(record) if reader is not None else [])
prov = build_provenance(
record,
ops=ops,
started_at="",
finished_at=record.last_checkin_at,
exit_code=None,
watchdog_fired=False,
)
self._send(200, provenance_to_dict(prov))
-215
View File
@@ -1,215 +0,0 @@
"""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"]
+3 -2
View File
@@ -167,8 +167,8 @@ class _Supervisor:
def start_all(self) -> None: def start_all(self) -> None:
for spec in self.specs: for spec in self.specs:
self.procs.append((spec, _spawn(spec)))
_log(f"starting {spec.name}") _log(f"starting {spec.name}")
self.procs.append((spec, _spawn(spec)))
def request_shutdown(self, reason: str) -> None: def request_shutdown(self, reason: str) -> None:
if self.shutdown_at is not None: if self.shutdown_at is not None:
@@ -353,6 +353,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0 return 0
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all()
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM")) # type: ignore signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM")) # type: ignore
signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT")) # type: ignore signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT")) # type: ignore
# SIGHUP reload path: egress_apply.py runs `docker kill # SIGHUP reload path: egress_apply.py runs `docker kill
@@ -360,7 +362,6 @@ def main(argv: Sequence[str] | None = None) -> int:
# delivers SIGHUP to PID 1 (this supervisor); forward it to # delivers SIGHUP to PID 1 (this supervisor); forward it to
# mitmdump so it reloads its addon. # mitmdump so it reloads its addon.
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore
sup.start_all()
while not sup.tick(): while not sup.tick():
time.sleep(_POLL_INTERVAL) time.sleep(_POLL_INTERVAL)
-60
View File
@@ -1,60 +0,0 @@
"""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"]
+349 -112
View File
@@ -9,14 +9,15 @@ calls when it needs an operator-reviewed egress change:
Each tool call: the agent passes the full proposed file plus a Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
writes it to the host SQLite queue table, and holds the tool-call writes it to the host's per-bottle queue dir, and holds the tool-call
connection open. The operator's supervise TUI connection open. The operator's supervise TUI
(bot_bottle.cli.supervise) sees the proposal, accepts (bot_bottle.cli.supervise) sees the proposal, accepts
approve / modify / reject, and writes a response row. The sidecar sees approve / modify / reject, and writes a response file alongside the
the response and returns `{status, notes}` to the agent. proposal. The sidecar sees the response and returns `{status, notes}`
to the agent.
This module defines the host-side library: dataclasses for the queue This module defines the host-side library: dataclasses for the queue
record shapes, queue read/write helpers, the audit log writer, and the file shapes, queue read/write helpers, the audit log writer, and the
diff renderer. The in-container sidecar lives in diff renderer. The in-container sidecar lives in
bot_bottle/supervise_server.py; the supervise daemon's container bot_bottle/supervise_server.py; the supervise daemon's container
lifecycle is owned by the sidecar bundle (PRD 0024). lifecycle is owned by the sidecar bundle (PRD 0024).
@@ -30,56 +31,37 @@ remediation engines that wire real config changes land in PRDs 0014,
from __future__ import annotations from __future__ import annotations
import dataclasses
import difflib import difflib
import hashlib import hashlib
import json
import os
import time import time
import uuid
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path 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_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 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 # The supervise sidecar uses these to query egress's
# introspection endpoint for the `list-egress-routes` MCP # introspection endpoint for the `list-egress-routes` MCP
# tool. The hostname + port match egress's docker network # tool. The hostname + port match egress's docker network
@@ -95,77 +77,236 @@ COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_EGRESS_BLOCK: "egress", TOOL_EGRESS_BLOCK: "egress",
} }
DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db" 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"
DEFAULT_POLL_INTERVAL_SEC = 0.5 DEFAULT_POLL_INTERVAL_SEC = 0.5
# --- Paths ----------------------------------------------------------------- # --- 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: def audit_dir() -> Path:
return bot_bottle_root() / "audit" 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: def audit_log_path(component: str, slug: str) -> Path:
return audit_dir() / f"{component}-{slug}.log" return audit_dir() / f"{component}-{slug}.log"
try: # --- Dataclasses -----------------------------------------------------------
from .queue_store import QueueStore
from .audit_store import AuditStore
from .store_manager import StoreManager @dataclass(frozen=True)
except ImportError: class Proposal:
# Sidecar bundle: files are flat-copied under /app, not a package. """One pending tool-call from the agent. The sidecar writes one
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module of these to the queue dir on a tool call; the operator's TUI
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module reads them; the sidecar polls for a matching Response."""
from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
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)
# --- Queue I/O ------------------------------------------------------------- # --- Queue I/O -------------------------------------------------------------
def write_proposal(proposal: Proposal) -> Path: def _proposal_filename(proposal_id: str) -> str:
"""Persist `proposal` in the queue database, mode 0o600. 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.
Directory is created if missing.""" Directory is created if missing."""
return QueueStore(proposal.bottle_slug).write_proposal(proposal) 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
def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal: def read_proposal(queue_dir: Path, proposal_id: str) -> Proposal:
return QueueStore(bottle_slug).read_proposal(proposal_id) 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 list_pending_proposals(bottle_slug: str) -> list[Proposal]: def list_pending_proposals(queue_dir: Path) -> list[Proposal]:
"""All proposals for `bottle_slug` that do not yet have a matching """All proposals in `queue_dir` that do not yet have a matching
response. Sorted by `arrival_timestamp` so the operator response file. Sorted by `arrival_timestamp` so the operator
sees the queue FIFO.""" sees the queue FIFO."""
return QueueStore(bottle_slug).list_pending_proposals() 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
def list_all_pending_proposals() -> list[Proposal]: def write_response(queue_dir: Path, response: Response) -> Path:
"""All pending proposals across bottles, sorted FIFO.""" queue_dir.mkdir(parents=True, exist_ok=True)
return QueueStore("").list_all_pending_proposals() 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 write_response(bottle_slug: str, response: Response) -> Path: def read_response(queue_dir: Path, proposal_id: str) -> Response:
return QueueStore(bottle_slug).write_response(response) path = queue_dir / _response_filename(proposal_id)
with path.open() as f:
raw = json.load(f)
def read_response(bottle_slug: str, proposal_id: str) -> Response: if not isinstance(raw, dict):
return QueueStore(bottle_slug).read_response(proposal_id) raise ValueError(f"{path}: top-level must be an object")
return Response.from_dict(raw)
def wait_for_response( def wait_for_response(
bottle_slug: str, queue_dir: Path,
proposal_id: str, proposal_id: str,
*, *,
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC, poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
@@ -176,35 +317,90 @@ def wait_for_response(
which the wait raises TimeoutError. None waits forever the which the wait raises TimeoutError. None waits forever the
natural shape, since the operator's response time is unbounded. natural shape, since the operator's response time is unbounded.
Polls SQLite so the implementation stays portable and stdlib-only.""" Polls the filesystem so the implementation stays portable and
store = QueueStore(bottle_slug) stdlib-only."""
path = queue_dir / _response_filename(proposal_id)
while True: while True:
if path.exists():
try: try:
return store.read_response(proposal_id) with path.open() as f:
except FileNotFoundError: raw = json.load(f)
except (OSError, json.JSONDecodeError):
raw = None
if isinstance(raw, dict):
try:
return Response.from_dict(raw)
except (KeyError, ValueError):
pass pass
if deadline is not None and time.monotonic() >= deadline: if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError(f"no response for proposal {proposal_id!r}") raise TimeoutError(f"no response for proposal {proposal_id!r}")
time.sleep(poll_interval) time.sleep(poll_interval)
def archive_proposal(bottle_slug: str, proposal_id: str) -> None: def archive_proposal(queue_dir: Path, proposal_id: str) -> None:
"""Mark both proposal and response rows processed. """Move both proposal and response files to `<queue_dir>/processed/`.
Idempotent missing rows are silently skipped.""" Idempotent missing files are silently skipped."""
QueueStore(bottle_slug).archive_proposal(proposal_id) 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)
# --- Audit log ------------------------------------------------------------- # --- Audit log -------------------------------------------------------------
def write_audit_entry(entry: AuditEntry) -> Path: def write_audit_entry(entry: AuditEntry) -> Path:
"""Append `entry` to the host supervise audit table.""" """Append `entry` as one JSON-Lines record to the per-bottle
return AuditStore().write_audit_entry(entry) 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
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]: def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
"""Load all audit entries for the given component+slug.""" """Load all audit entries for the given component+slug. Empty
return AuditStore().read_audit_entries(component, slug) 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
# --- Diff rendering -------------------------------------------------------- # --- Diff rendering --------------------------------------------------------
@@ -237,47 +433,89 @@ def sha256_hex(content: str) -> str:
class SupervisePlan: class SupervisePlan:
"""Output of Supervise.prepare; consumed by .start. """Output of Supervise.prepare; consumed by .start.
`db_path` is the host database bind-mounted into the sidecar at `queue_dir` is the host directory bind-mounted into the sidecar
/run/supervise/bot-bottle.db. `internal_network` is empty at at /run/supervise/queue. `internal_network` is empty at prepare
prepare time; the backend's launch step fills it via time; the backend's launch step fills it via dataclasses.replace
dataclasses.replace before calling .start.""" before calling .start."""
slug: str slug: str
db_path: Path queue_dir: Path
internal_network: str = "" internal_network: str = ""
class Supervise(ABC): class Supervise(ABC):
"""Per-bottle supervise sidecar. Encapsulates host-side database """Per-bottle supervise sidecar. Encapsulates the host-side
staging; the sidecar's start/stop lifecycle is backend-specific.""" prepare (queue dir staging); the sidecar's start/stop lifecycle
is backend-specific."""
def prepare( def prepare(
self, self,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
) -> SupervisePlan: ) -> SupervisePlan:
"""Stage the host database. Returns the plan; `internal_network` """Stage the per-bottle queue dir on the host. Returns the
must be set by the launch step before .start runs.""" plan; `internal_network` must be set by the launch step before
.start runs."""
del stage_dir del stage_dir
mgr = StoreManager.instance() queue_dir = queue_dir_for_slug(slug)
mgr.migrate() queue_dir.mkdir(parents=True, exist_ok=True)
return SupervisePlan( return SupervisePlan(
slug=slug, slug=slug,
db_path=mgr.db_path, queue_dir=queue_dir,
) )
# --- 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__ = [ __all__ = [
"ACTION_OPERATOR_EDIT", "ACTION_OPERATOR_EDIT",
"AuditEntry", "AuditEntry",
"AuditStore",
"COMPONENT_FOR_TOOL", "COMPONENT_FOR_TOOL",
"DEFAULT_POLL_INTERVAL_SEC", "DEFAULT_POLL_INTERVAL_SEC",
"DB_PATH_IN_CONTAINER",
"Proposal", "Proposal",
"QueueStore", "QUEUE_DIR_IN_CONTAINER",
"Response", "Response",
"StoreManager",
"STATUSES", "STATUSES",
"STATUS_APPROVED", "STATUS_APPROVED",
"STATUS_MODIFIED", "STATUS_MODIFIED",
@@ -298,9 +536,8 @@ __all__ = [
"audit_dir", "audit_dir",
"audit_log_path", "audit_log_path",
"bot_bottle_root", "bot_bottle_root",
"host_db_path",
"list_pending_proposals", "list_pending_proposals",
"list_all_pending_proposals", "queue_dir_for_slug",
"read_audit_entries", "read_audit_entries",
"read_proposal", "read_proposal",
"read_response", "read_response",
+17 -9
View File
@@ -7,13 +7,14 @@ config changes when stuck. The tools are `egress-allow`,
Each queued tool call: Each queued tool call:
1. Validates the proposed file syntactically. 1. Validates the proposed file syntactically.
2. Writes a Proposal to the host SQLite database. 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
3. Blocks polling for a matching Response row. the host's ~/.bot-bottle/queue/<slug>/).
3. Blocks polling for a matching Response file.
4. Returns the operator's `{status, notes}` to the agent. 4. Returns the operator's `{status, notes}` to the agent.
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
container creation by the backend's start step). SUPERVISE_DB_PATH container creation by the backend's start step). The queue dir comes
points at the bind-mounted host database. from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`).
Speaks MCP over HTTP+JSON-RPC. Methods handled: Speaks MCP over HTTP+JSON-RPC. Methods handled:
@@ -41,6 +42,7 @@ import typing
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
try: try:
# Same-directory imports inside the bundle container; these files are # Same-directory imports inside the bundle container; these files are
@@ -275,6 +277,7 @@ def validate_proposed_file(tool: str, content: str) -> None:
@dataclass(frozen=True) @dataclass(frozen=True)
class ServerConfig: class ServerConfig:
bottle_slug: str bottle_slug: str
queue_dir: Path
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
@@ -373,7 +376,7 @@ def handle_tools_call(
current_file_hash=_sv.sha256_hex(proposed_file), current_file_hash=_sv.sha256_hex(proposed_file),
) )
try: try:
_sv.write_proposal(proposal) _sv.write_proposal(config.queue_dir, proposal)
except OSError as e: except OSError as e:
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
sys.stderr.write( sys.stderr.write(
@@ -384,7 +387,7 @@ def handle_tools_call(
deadline = time.monotonic() + config.response_timeout_seconds deadline = time.monotonic() + config.response_timeout_seconds
try: try:
response = _sv.wait_for_response( response = _sv.wait_for_response(
config.bottle_slug, config.queue_dir,
proposal.id, proposal.id,
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS, poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
deadline=deadline, deadline=deadline,
@@ -396,7 +399,7 @@ def handle_tools_call(
"isError": False, "isError": False,
} }
try: try:
_sv.archive_proposal(config.bottle_slug, proposal.id) _sv.archive_proposal(config.queue_dir, proposal.id)
except OSError as e: except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e raise _RpcInternalError(f"failed to archive proposal: {e}") from e
@@ -536,7 +539,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="") config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path())
# --- Entry point ----------------------------------------------------------- # --- Entry point -----------------------------------------------------------
@@ -545,18 +548,21 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def serve( def serve(
*, *,
bottle_slug: str, bottle_slug: str,
queue_dir: Path,
port: int = _sv.SUPERVISE_PORT, port: int = _sv.SUPERVISE_PORT,
bind: str = "0.0.0.0", bind: str = "0.0.0.0",
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS, response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
) -> typing.NoReturn: ) -> typing.NoReturn:
queue_dir.mkdir(parents=True, exist_ok=True)
server = MCPServer((bind, port), MCPHandler) server = MCPServer((bind, port), MCPHandler)
server.config = ServerConfig( server.config = ServerConfig(
bottle_slug=bottle_slug, bottle_slug=bottle_slug,
queue_dir=queue_dir,
response_timeout_seconds=response_timeout_seconds, response_timeout_seconds=response_timeout_seconds,
) )
sys.stderr.write( sys.stderr.write(
f"supervise listening on {bind}:{port}; " f"supervise listening on {bind}:{port}; "
f"slug={bottle_slug!r}; " f"slug={bottle_slug!r}; queue={queue_dir}; "
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type] f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
) )
sys.stderr.flush() sys.stderr.flush()
@@ -575,6 +581,7 @@ def main(argv: list[str]) -> int:
if not bottle_slug: if not bottle_slug:
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n") sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
return 2 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))) port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0") bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
try: try:
@@ -584,6 +591,7 @@ def main(argv: list[str]) -> int:
return 2 return 2
serve( serve(
bottle_slug=bottle_slug, bottle_slug=bottle_slug,
queue_dir=queue_dir,
port=port, port=port,
bind=bind, bind=bind,
response_timeout_seconds=response_timeout_seconds, response_timeout_seconds=response_timeout_seconds,
-189
View File
@@ -1,189 +0,0 @@
"""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",
]
-107
View File
@@ -21,11 +21,6 @@ Public API:
For a Markdown file with YAML frontmatter delimited by `---` For a Markdown file with YAML frontmatter delimited by `---`
lines. Returns (frontmatter_dict, body_text). lines. Returns (frontmatter_dict, body_text).
serialize_yaml_subset(data) -> str
Serialize a dict (as produced by parse_yaml_subset) back to
block-style YAML text. The result ends with a newline and
can be parsed back by parse_yaml_subset.
What we accept (block-style): What we accept (block-style):
key: value # mapping entry, value is inline key: value # mapping entry, value is inline
@@ -581,105 +576,3 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
fm = parse_yaml_subset(fm_text) fm = parse_yaml_subset(fm_text)
body = text[body_start:] body = text[body_start:]
return fm, body return fm, body
# --- Serializer -------------------------------------------------------------
def _needs_quoting(s: str) -> bool:
"""True when the string must be single-quoted to survive a round-trip."""
if not s:
return True
if s in ("true", "false", "null", "~") or s in _RESERVED_BOOL_LIKE:
return True
if (
_INT_RX.match(s)
or _DATE_RX.match(s)
or _OCTAL_RX.match(s)
or _HEX_RX.match(s)
or _FLOAT_RX.match(s)
):
return True
# Characters that have special meaning at the start of a YAML value
if s[0] in ('"', "'", "[", "{", "!", "&", "*", "#", "|", ">", "%", "@", "`"):
return True
return False
def _yaml_scalar(v: object) -> str:
"""Serialize a scalar Python value to its YAML text form."""
if v is None:
return "null"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
s = str(v)
if _needs_quoting(s):
return "'" + s.replace("'", "''") + "'"
return s
def _serialize_node(node: object, indent: int) -> list[str]:
"""Return lines (without trailing newlines) for `node` at `indent`.
Called only for non-empty dicts and lists (the caller guards with
`isinstance(val, (dict, list)) and val`), plus scalars at the leaf."""
prefix = " " * indent
if isinstance(node, dict):
out: list[str] = []
for key, val in node.items():
if isinstance(val, (dict, list)) and val:
out.append(f"{prefix}{key}:")
out.extend(_serialize_node(val, indent + 2))
else:
scalar = (
"{}" if isinstance(val, dict)
else "[]" if isinstance(val, list)
else _yaml_scalar(val)
)
out.append(f"{prefix}{key}: {scalar}")
return out
if isinstance(node, list):
out = []
for item in node:
if isinstance(item, dict) and item:
entries = list(item.items())
first_key, first_val = entries[0]
if isinstance(first_val, (dict, list)) and first_val:
out.append(f"{prefix}- {first_key}:")
out.extend(_serialize_node(first_val, indent + 4))
else:
scalar = (
"{}" if isinstance(first_val, dict)
else "[]" if isinstance(first_val, list)
else _yaml_scalar(first_val)
)
out.append(f"{prefix}- {first_key}: {scalar}")
cont = prefix + " "
for key, val in entries[1:]:
if isinstance(val, (dict, list)) and val:
out.append(f"{cont}{key}:")
out.extend(_serialize_node(val, indent + 4))
else:
scalar = (
"{}" if isinstance(val, dict)
else "[]" if isinstance(val, list)
else _yaml_scalar(val)
)
out.append(f"{cont}{key}: {scalar}")
else:
out.append(f"{prefix}- {_yaml_scalar(item)}")
return out
return [_yaml_scalar(node)] # pragma: no cover
def serialize_yaml_subset(data: dict[str, object]) -> str:
"""Serialize `data` (as produced by parse_yaml_subset) to YAML text.
Produces block-style output with 2-space indentation. The result ends
with a newline and can be parsed back by parse_yaml_subset. Keys are
emitted in iteration order (insertion order in Python 3.7+)."""
if not data:
return ""
return "\n".join(_serialize_node(data, 0)) + "\n"
-135
View File
@@ -1,135 +0,0 @@
# 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.
-227
View File
@@ -1,227 +0,0 @@
# PRD 0068: smolmachines backend on Linux
- **Status:** Active
- **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
@@ -0,0 +1,146 @@
# PRD prd-new: Claude forward_host_credentials
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-07-01
- **Issue:** #325
## Summary
Add `agent_provider.forward_host_credentials: true` support for the
`claude` template, mirroring the existing Codex flow. When enabled,
bot-bottle reads the host's Claude OAuth session key from
`~/.claude.json` at launch, forwards it only to the egress sidecar,
and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so
Claude Code starts without ever seeing the real credential.
## Problem
Running a Claude agent in a container today requires the operator to
manually extract a long-lived OAuth token (`claude setup-token`), export
it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in
the manifest with `agent_provider.auth_token:
"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony
that is easy to skip or do incorrectly.
The host already stores a valid Claude session in `~/.claude.json` after
`claude login` or `claude setup-token`. Codex already automates an
equivalent extraction from `~/.codex/auth.json`. There is no reason
Claude bottles cannot do the same.
## Goals / Success Criteria
- A Claude bottle with `forward_host_credentials: true` in the manifest
uses the host's `~/.claude.json` session key at launch with no
additional operator steps.
- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`
— never the real token.
- The real session key lives only in the egress sidecar's environment.
- Missing, malformed, or expired host Claude auth fails launch with a
clear operator-facing message.
- Existing `auth_token` behavior is unchanged.
- `forward_host_credentials: true` is rejected in the manifest when both
`auth_token` and `forward_host_credentials` are set, since they serve
the same purpose.
## Non-goals
- Refreshing Claude OAuth tokens in the sidecar.
- Writing a dummy `~/.claude.json` auth state to the agent (unlike the
Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN`
in env, not from an auth file — no guest-side auth marker is needed).
- Supporting `forward_host_credentials` for providers other than `codex`
and `claude`.
## Design
### Manifest schema
```yaml
agent_provider:
template: claude
forward_host_credentials: true
```
Rejects in manifest validation when:
- Template is not `codex` or `claude`.
- Both `auth_token` and `forward_host_credentials` are set.
### Host auth extraction (`contrib/claude/claude_auth.py`)
Claude Code credential storage varies by platform:
- **Linux**: `~/.claude/.credentials.json`
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
(the file path is tried first; Keychain is the fallback when the file
is absent)
`~/.claude.json` contains only UI state and profile metadata — no token.
The credentials JSON schema (same whether from file or Keychain):
```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1748276587173,
"scopes": ["user:inference", "user:profile"]
}
}
```
`expiresAt` is in **milliseconds** (not seconds).
At prepare/launch time, when `forward_host_credentials: true`:
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
`security find-generic-password -s "Claude Code-credentials" -w`
and parse its stdout as JSON.
2. Require a `claudeAiOauth` dict.
3. Require a non-empty `claudeAiOauth.accessToken` string.
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
the result to be in the future.
5. Return only the access token to the launch path.
Errors name the missing or invalid condition and point the operator at
`claude login`, without printing token values.
### Egress route
When `forward_host_credentials: true`:
- Provision the session key in `provisioned_env` under
`BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`).
- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer`
and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`.
- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and
add it to `hidden_env_names`.
No dummy auth file and no `verify` step are needed — Claude Code reads
the credential from the env var, not from a file.
### Constants
- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"`
in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`).
- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in
`agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`).
### Data flow
```
Host ~/.claude.json → bot-bottle launch
├──► egress sidecar env (real token only)
└──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder
Agent → HTTPS to api.anthropic.com (via egress)
Egress → injects Authorization: Bearer <real token>
Egress → forwards to api.anthropic.com
```
## Open questions
None — the Codex precedent makes the design clear.
@@ -0,0 +1,132 @@
# PRD prd-new: Fold bot-bottle-orchestrator into this repo
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-07-01
- **Issue:** #321
## Summary
Move the `bot-bottle-orchestrator` binary into `bot_bottle/orchestrator/` as a
first-class subpackage. `pip install bot-bottle` gets you everything; the
orchestrator's entry point becomes `python -m bot_bottle.orchestrator run`. The
cross-repo CLI contract becomes an internal boundary, and the forge integration
layer (`GiteaClient`, `ScopedForge`, `SqliteForgeStateStore`) is promoted to
`bot_bottle/contrib/` where it belongs.
## Problem
The orchestrator and bot-bottle are tightly coupled:
- It always deploys on the same host.
- It imports from `bot_bottle` for the forge/state layer.
- Its runner shims (`start --headless`, `commit`, `resume`) map 1:1 to CLI
commands in `cli.py` — a breaking CLI change silently breaks the orchestrator
with no CI signal.
- Two repos means two version pins, two CI pipelines, and two install steps
every time the deploy environment is rebuilt.
## Goals / Success Criteria
- All orchestrator modules live under `bot_bottle/orchestrator/` and the package
is importable as `from bot_bottle.orchestrator import ...`.
- `python -m bot_bottle.orchestrator run` starts the webhook server.
- `python -m bot_bottle.orchestrator status` prints tracked runs.
- The forge integration layer (`GiteaClient`, `GiteaForge`, `ScopedForge`,
`ForgeState`, `SqliteForgeStateStore`) lives in `bot_bottle/contrib/` and is
covered by tests in `tests/unit/orchestrator/`.
- All orchestrator unit tests pass under bot-bottle's existing CI
(`python -m unittest discover -s tests/unit`).
- No functional change to the orchestrator's external behaviour: same
HTTP surface, same webhook protocol, same env-var config, same CLI flags.
## Non-goals
- Replacing `SubprocessBottleRunner` with a direct programmatic runner — the
subprocess shim stays; the `BottleRunner` protocol remains the internal
abstraction point.
- Merging the orchestrator's SQLite DB with any other bot-bottle state store.
- Archiving `bot-bottle-orchestrator` (that happens after this ships and the
deploy is updated; out of scope for this PR).
## Design
### Package layout
```
bot_bottle/
orchestrator/
__init__.py
__main__.py # python -m bot_bottle.orchestrator
bootstrap.py # wires contrib modules → orchestrator core
config.py
events.py
lifecycle.py
model.py
provenance.py
runner.py
sidecar.py
store.py
targeting.py
watchdog.py
webhook.py
contrib/
forge/
__init__.py
base.py # ScopedForge: read-anywhere / write-scoped wrapper
gitea/
client.py # GiteaClient (urllib.request), GiteaForge
forge_state.py # ForgeState dataclass + SqliteForgeStateStore
tests/unit/orchestrator/
__init__.py
_fakes.py
test_config.py
test_events.py
test_lifecycle.py
test_provenance.py
test_runner.py
test_sidecar.py
test_store.py
test_targeting.py
test_watchdog.py
test_webhook.py
```
### Module moves
Every `orchestrator/` source file moves verbatim into `bot_bottle/orchestrator/`.
Internal imports are already relative (`from .config import Config`) so no
changes are needed inside the orchestrator modules themselves.
`bootstrap.py` is the only file that changes meaningfully: the lazy `bot_bottle`
imports become direct relative imports (`from ..contrib.gitea.client import …`),
and the `_require_bot_bottle()` guard is removed since the package is always
present.
### New contrib modules
**`bot_bottle/contrib/forge/base.py``ScopedForge`**
Wraps any forge object and enforces read-anywhere / write-scoped access: reads
pass through unconditionally; `post_comment` and `update_description` raise
`PermissionError` for issue/PR numbers outside the assigned set.
**`bot_bottle/contrib/gitea/client.py``GiteaClient`, `GiteaForge`**
`GiteaClient` is a thin `urllib.request`-only HTTP wrapper (no new Python
dependencies). `GiteaForge` composes a client and exposes the forge protocol:
`is_org_member`, `read_issue`, `read_pr`, `read_comments`, `post_comment`,
`update_description`.
**`bot_bottle/contrib/gitea/forge_state.py``ForgeState`, `SqliteForgeStateStore`**
`ForgeState` is a dataclass mirroring `RunRecord` field-for-field. `SqliteForgeStateStore`
backs it with SQLite (stdlib `sqlite3`): a single `forge_state` table with one
row per (owner, repo, issue\_number).
### Test migration
All orchestrator test files move to `tests/unit/orchestrator/` with absolute
imports updated from `orchestrator.X` to `bot_bottle.orchestrator.X`. The unit
discovery command (`-s tests/unit`) picks them up automatically — no CI changes
required.
@@ -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.
+13 -69
View File
@@ -1,20 +1,14 @@
# Landscape: containerized AI coding agent tools # Landscape: containerized Claude Code agent tools
Research into whether bot-bottle is redundant with existing projects, and Research into whether bot-bottle is redundant with existing projects, and
whether it's worth publishing. whether it's worth publishing.
## Summary ## Summary
The "AI coding agents in isolated sandboxes" space is active but not saturated. The "Claude Code in Docker" space is active but not saturated. bot-bottle
bot-bottle occupies a distinct position: no surveyed project combines all five occupies a distinct position: no surveyed project combines all five of its
of its defining features. Publishing is likely worthwhile, with the main risk defining features. Publishing is likely worthwhile, with the main risk being
being claudebox expanding to absorb the same niche. claudebox expanding to absorb the same niche.
**Updated 2026-07-09:** bot-bottle now supports three isolation backends
(Docker, Apple `container`, smolmachines/libkrun microVMs) and three built-in
agent providers (Claude Code, OpenAI Codex, Pi) with an open plugin system for
arbitrary providers. This meaningfully strengthens the differentiation against
all surveyed competitors.
## Closest competitor: claudebox ## Closest competitor: claudebox
@@ -49,78 +43,28 @@ manifest merge.
Still marked early-development. Still marked early-development.
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox - **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
runtimes; fundamentally different architecture. runtimes; fundamentally different architecture.
- **superhq.ai / SuperHQ** (v0.4.4, April 2026) — macOS desktop app (Rust/GPUI)
that runs Claude Code, Codex, and Pi inside microVMs via Apple's
Virtualization.framework (their own shuru-sdk / libkrun). Auth gateway
injects API keys on the wire so the sandbox never sees them; tmpfs overlay
stages agent writes for diff-and-accept review; mobile remote access via
remote.superhq.ai. Early alpha, free on launch, Apple Silicon only.
Overlap: both projects cover agent isolation, credential proxying, and
multi-provider support (Claude Code / Codex / Pi). Differences: SuperHQ is a
GUI desktop app with no manifest layer; bot-bottle is a CLI fleet manager with
named agents, skills injection, per-agent system prompts, and cross-platform
backends (Docker, Apple `container`, smolmachines). SuperHQ's microVM
isolation story is now partially matched by bot-bottle's `macos_container` and
smolmachines backends. Worth watching — it targets the same security-minded
power-user audience and moves fast.
**Known gap in SuperHQ (user-requested, as of 2026-07-09):** A named user
(Brian Cheong, Founder, Dunialabs.io) explicitly called out the absence of
per-run audit logging: tool calls and network egress. Bot-bottle covers both:
network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state
is persisted to SQLite.
## What no found project does ## What no found project does
None combine: None combine:
1. Named-agent manifest with per-agent env resolution (prompt / host-forward / literal), supporting multiple providers (Claude Code, Codex, Pi, arbitrary plugins) 1. Named-agent JSON manifest with per-agent env resolution (prompt / host-forward / literal)
2. Skills directory injection 2. Claude Code skills directory injection
3. Per-agent system prompts 3. Per-agent system prompts
4. SSH-agent key forwarding without copying private keys into the container 4. SSH-agent key forwarding without copying private keys into the container
5. Home + project manifest merge 5. Home + project manifest merge
6. Pluggable isolation backends: Docker (Linux/macOS), Apple `container` (macOS microVMs), smolmachines/libkrun microVMs
7. Per-run audit log: network egress via pipelock/mitmproxy + op-log persisted to SQLite
**In-flight directions (not yet shipped):**
- **Forge-native dispatch (issue #317):** Gitea webhook → orchestrator spins up a bottle
with the issue body as prompt → agent works → bottle freezes awaiting review comment →
rehydrates on comment → tears down on PR close. The issue-to-PR lifecycle concept is not
novel (Devin, Copilot Workspace, SWE-agent all do this as cloud services); what's
distinct is doing it self-hosted, manifest-driven, inside bot-bottle's isolation
primitives.
- **Paid web control plane (issue #327):** Browser-based multi-host agent launch and
monitoring; account-scoped bottle and agent definitions; secret custody (encrypted at
rest, injected into the sidecar at launch, never exposed to the agent or returned by any
read API). Monetization model: OSS runtime free, control plane paid — a standard split
(HashiCorp, Grafana) applied to a self-hosted agent sandbox. The principled secret
custody model (agent never sees real credentials, even via printenv) is more rigorous
than most surveyed tools but not unprecedented.
## Publishing verdict ## Publishing verdict
Worth publishing. Differentiators that matter to the target audience (power Worth publishing. Differentiators that matter to the target audience (power
users running parallel AI coding agent sessions with distinct personas/tooling): users running parallel Claude Code sessions with distinct personas/tooling):
- The Python-stdlib-first, low-dependency design — competitors are npm-based, - The Python-stdlib-first, low-dependency design — competitors are npm-based or
Rust/GUI, or Kubernetes-native. Kubernetes-native.
- Named agents with distinct skills and system prompts, not just language profiles. - Named agents with distinct skills and system prompts, not just language profiles.
- Multi-backend isolation: Docker, Apple `container` microVMs, and
smolmachines/libkrun — single manifest works across all three.
- Multi-provider: Claude Code, Codex, Pi, plus an open plugin system for
arbitrary providers.
- SSH forwarding without key copying. - SSH forwarding without key copying.
- Per-run audit log (tool calls + network egress) — an explicitly requested gap
in SuperHQ as of 2026-07-09.
- Forge-native dispatch and a paid control plane (in flight) bring bot-bottle
into the same product category as cloud services like Devin and Copilot
Workspace — but self-hosted, with stronger isolation guarantees and a
manifest-driven fleet model those services don't have.
Main risk: claudebox adds manifest/agent config; SuperHQ is moving fast on the Main risk: claudebox adds manifest/agent config. The space is moving fast
GUI / microVM side. The space is moving fast enough that publishing sooner is enough that publishing sooner is better if establishing prior art matters.
better if establishing prior art matters.
Discovery will be slow without active promotion; an Anthropic Discord post or Discovery will be slow without active promotion; an Anthropic Discord post or
HN "Show HN" would do most of the work. HN "Show HN" would do most of the work.
@@ -129,4 +73,4 @@ HN "Show HN" would do most of the work.
- GitHub search cannot surface private or very new repos comprehensively. - GitHub search cannot surface private or very new repos comprehensively.
- Counts (stars, forks) were not confirmed for every project. - Counts (stars, forks) were not confirmed for every project.
- Initial research conducted 2026-05-07; SuperHQ entry added 2026-07-09; the space moves fast. - Research conducted 2026-05-07; the space moves fast.
+1 -1
View File
@@ -3,5 +3,5 @@
# These tools are used for code quality checks in CI/CD. # These tools are used for code quality checks in CI/CD.
pylint>=3.0.0 pylint>=3.0.0
pyright>=1.1.411 pyright>=1.1.300
coverage>=7.0.0 coverage>=7.0.0
+1 -1
View File
@@ -22,4 +22,4 @@ bot_bottle/git_gate_provision.py
bot_bottle/git_http_backend.py bot_bottle/git_http_backend.py
bot_bottle/supervise.py bot_bottle/supervise.py
bot_bottle/yaml_subset.py bot_bottle/yaml_subset.py
bot_bottle/bottle/state.py bot_bottle/bottle_state.py
+4 -4
View File
@@ -30,7 +30,7 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.bottle.state import cleanup_state from bot_bottle.bottle_state import cleanup_state
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
@@ -81,10 +81,10 @@ class TestSandboxEscape(unittest.TestCase):
# those are missing rather than die-ing inside backend.prepare. # those are missing rather than die-ing inside backend.prepare.
backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker") backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
if backend_name == "smolmachines": if backend_name == "smolmachines":
if sys.platform not in ("darwin", "linux"): if sys.platform != "darwin":
raise unittest.SkipTest( raise unittest.SkipTest(
f"BOT_BOTTLE_BACKEND=smolmachines is not supported " "BOT_BOTTLE_BACKEND=smolmachines is macOS-only in "
f"on {sys.platform} (macOS and Linux only)" "v1 (libkrun TSI)"
) )
if shutil.which("smolvm") is None: if shutil.which("smolvm") is None:
raise unittest.SkipTest( raise unittest.SkipTest(
+4 -12
View File
@@ -21,7 +21,7 @@ security properties the design pivot was about:
bind-address mitigation is what closes TSI's port-granularity bind-address mitigation is what closes TSI's port-granularity
gap. gap.
Gated on macOS/Linux + smolvm + docker + not GITEA_ACTIONS the Gated on macOS + smolvm + docker + not GITEA_ACTIONS the
runner can't host libkrun-backed VMs.""" runner can't host libkrun-backed VMs."""
from __future__ import annotations from __future__ import annotations
@@ -65,8 +65,8 @@ def _minimal_manifest() -> ManifestIndex:
@skip_unless_docker() @skip_unless_docker()
@unittest.skipUnless( @unittest.skipUnless(
platform.system() in ("Darwin", "Linux"), platform.system() == "Darwin",
"smolvm requires macOS or Linux", "smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
) )
@unittest.skipUnless( @unittest.skipUnless(
_smolvm_available(), _smolvm_available(),
@@ -141,15 +141,7 @@ class TestSmolmachinesLaunch(unittest.TestCase):
proxies = [line.strip() for line in r.stdout.splitlines()] proxies = [line.strip() for line in r.stdout.splitlines()]
self.assertEqual(2, len(proxies), proxies) self.assertEqual(2, len(proxies), proxies)
self.assertEqual(proxies[0], proxies[1], proxies) self.assertEqual(proxies[0], proxies[1], proxies)
# macOS: proxy binds to the per-bottle loopback alias (127.x.x.x) so self.assertTrue(proxies[0].startswith("http://127."), proxies[0])
# 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( r = self.bottle.exec(
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK" "curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
@@ -2,7 +2,7 @@
exercised against the real binary. exercised against the real binary.
The full machine-lifecycle round trip (create start exec The full machine-lifecycle round trip (create start exec
delete) is gated behind macOS/Linux platform check and lives delete) is gated behind macOS + Darwin platform check and lives
in chunk 2d's smoke. This file just verifies `is_available()` in chunk 2d's smoke. This file just verifies `is_available()`
correctly reports presence and `_smolvm()` can run a no-op correctly reports presence and `_smolvm()` can run a no-op
subcommand without errors enough to flag wrapper drift if 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", "skipped under act_runner: smolvm not installed on the runner",
) )
@unittest.skipUnless( @unittest.skipUnless(
platform.system() in ("Darwin", "Linux"), platform.system() == "Darwin",
"smolvm requires macOS or Linux", "smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
) )
@unittest.skipUnless( @unittest.skipUnless(
is_available(), is_available(),
View File
+69
View File
@@ -0,0 +1,69 @@
"""Shared test doubles: a duck-typed forge and bottle runner."""
# Test doubles mirror an API shape; some params are intentionally unused.
# pylint: disable=unused-argument
from __future__ import annotations
from collections.abc import Sequence
from bot_bottle.orchestrator.runner import RunResult, slugify
class FakeForge:
def __init__(self, members: tuple[str, ...] = ()) -> None:
self.members = set(members)
self.comments: list[tuple[int, str]] = []
self.descriptions: list[tuple[int, str]] = []
self.scope_denied: set[int] = set()
def is_org_member(self, org: str, username: str) -> bool:
return username in self.members
def read_issue(self, number: int) -> dict[str, object]:
return {"number": number, "kind": "issue"}
def read_pr(self, number: int) -> dict[str, object]:
return {"number": number, "merged": False}
def read_comments(self, number: int) -> list[dict[str, object]]:
return [{"id": 1, "user": "alice", "body": "hi"}]
def post_comment(self, number: int, body: str) -> None:
if number in self.scope_denied:
raise PermissionError(f"write to #{number} denied")
self.comments.append((number, body))
def update_description(self, number: int, body: str) -> None:
if number in self.scope_denied:
raise PermissionError(f"write to #{number} denied")
self.descriptions.append((number, body))
class FakeRunner:
def __init__(self) -> None:
self.calls: list[tuple[object, ...]] = []
def start(
self,
*,
agent: str,
bottles: Sequence[str],
label: str,
prompt: str,
forge_env: dict[str, str],
) -> RunResult:
self.calls.append(("start", agent, tuple(bottles), label, prompt, dict(forge_env)))
return RunResult(slug=slugify(label), exit_code=0)
def freeze(self, slug: str) -> int:
self.calls.append(("freeze", slug))
return 0
def resume(self, slug: str, prompt: str) -> RunResult:
self.calls.append(("resume", slug, prompt))
return RunResult(slug=slug, exit_code=0)
def destroy(self, slug: str) -> int:
self.calls.append(("destroy", slug))
return 0
+179
View File
@@ -0,0 +1,179 @@
"""Unit: BotBottleStateStore, _token, conversions, make_forge/make_sidecar, build."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle.orchestrator.bootstrap import (
BotBottleStateStore,
_to_forge_state,
_to_record,
_token,
build,
make_forge,
make_sidecar,
)
from bot_bottle.orchestrator.config import Config
from bot_bottle.orchestrator.model import RunRecord
def _config(tmp: str) -> Config:
return Config(
forge_org="org",
gitea_api="http://g/api/v1",
watchdog_timeout_secs=1800,
webhook_host="127.0.0.1",
webhook_port=0,
bot_bottle_cli="cli.py",
queue_dir=Path(tmp) / "q",
sidecar_socket=Path(tmp) / "s.sock",
db_path=None,
)
def _record(**kw: object) -> RunRecord:
defaults: dict[str, object] = {
"owner": "o", "repo": "r", "issue_number": 1, "slug": "s1", "agent_name": "a",
"bottle_names": ["claude"], "backend_name": "docker", "agent_git_user": "bot",
"pr_number": 5, "status": "running", "last_checkin_at": "2026-01-01T00:00:00+00:00",
}
defaults.update(kw)
return RunRecord(**defaults) # type: ignore[arg-type]
class TokenTest(unittest.TestCase):
def test_gitea_token_env(self):
with patch.dict(os.environ, {"GITEA_TOKEN": "tok123"}):
self.assertEqual("tok123", _token())
def test_forge_gitea_token_fallback(self):
clean = {k: v for k, v in os.environ.items()
if k not in ("GITEA_TOKEN", "FORGE_GITEA_TOKEN")}
with patch.dict(os.environ, {**clean, "FORGE_GITEA_TOKEN": "tok456"}, clear=True):
self.assertEqual("tok456", _token())
def test_missing_token_raises(self):
clean = {k: v for k, v in os.environ.items()
if k not in ("GITEA_TOKEN", "FORGE_GITEA_TOKEN")}
with patch.dict(os.environ, clean, clear=True):
with self.assertRaises(RuntimeError):
_token()
class ConversionRoundTripTest(unittest.TestCase):
def test_record_survives_forge_state_roundtrip(self):
rec = _record()
result = _to_record(_to_forge_state(rec))
self.assertEqual(rec.owner, result.owner)
self.assertEqual(rec.repo, result.repo)
self.assertEqual(rec.issue_number, result.issue_number)
self.assertEqual(rec.slug, result.slug)
self.assertEqual(rec.agent_name, result.agent_name)
self.assertEqual(rec.bottle_names, result.bottle_names)
self.assertEqual(rec.backend_name, result.backend_name)
self.assertEqual(rec.agent_git_user, result.agent_git_user)
self.assertEqual(rec.pr_number, result.pr_number)
self.assertEqual(rec.status, result.status)
self.assertEqual(rec.last_checkin_at, result.last_checkin_at)
def test_none_pr_number_preserved(self):
rec = _record(pr_number=None)
result = _to_record(_to_forge_state(rec))
self.assertIsNone(result.pr_number)
class BotBottleStateStoreTest(unittest.TestCase):
def setUp(self):
self.store = BotBottleStateStore(None)
def test_upsert_and_get(self):
self.store.upsert(_record())
got = self.store.get("o", "r", 1)
assert got is not None
self.assertEqual("s1", got.slug)
def test_get_missing(self):
self.assertIsNone(self.store.get("o", "r", 99))
def test_upsert_replaces(self):
self.store.upsert(_record())
self.store.upsert(_record(slug="new-slug"))
got = self.store.get("o", "r", 1)
assert got is not None
self.assertEqual("new-slug", got.slug)
def test_delete(self):
self.store.upsert(_record())
self.store.delete("o", "r", 1)
self.assertIsNone(self.store.get("o", "r", 1))
def test_all_returns_all_records(self):
self.store.upsert(_record(issue_number=1, slug="s1"))
self.store.upsert(_record(issue_number=2, slug="s2"))
recs = self.store.all()
self.assertEqual(2, len(recs))
slugs = {r.slug for r in recs}
self.assertEqual({"s1", "s2"}, slugs)
def test_all_empty(self):
self.assertEqual([], self.store.all())
def test_bottle_names_preserved(self):
self.store.upsert(_record(bottle_names=["claude", "dev"]))
got = self.store.get("o", "r", 1)
assert got is not None
self.assertEqual(["claude", "dev"], got.bottle_names)
class MakeForgeTest(unittest.TestCase):
def test_returns_gitea_forge(self):
with tempfile.TemporaryDirectory() as tmp:
config = _config(tmp)
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
forge = make_forge(config, "owner", "repo")
from bot_bottle.contrib.gitea.client import GiteaForge
self.assertIsInstance(forge, GiteaForge)
class MakeSidecarTest(unittest.TestCase):
def test_returns_forge_sidecar(self):
with tempfile.TemporaryDirectory() as tmp:
config = _config(tmp)
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
sidecar = make_sidecar(config, "owner", "repo", 1, [])
from bot_bottle.orchestrator.sidecar import ForgeSidecar
self.assertIsInstance(sidecar, ForgeSidecar)
class BuildTest(unittest.TestCase):
def test_returns_server_watchdog_orchestrator(self):
with tempfile.TemporaryDirectory() as tmp:
config = _config(tmp)
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
server, watchdog, orch = build(config)
server.server_close()
from bot_bottle.orchestrator.lifecycle import Orchestrator
from bot_bottle.orchestrator.watchdog import Watchdog
from bot_bottle.orchestrator.webhook import WebhookServer
self.assertIsInstance(server, WebhookServer)
self.assertIsInstance(watchdog, Watchdog)
self.assertIsInstance(orch, Orchestrator)
def test_server_binds_to_configured_host(self):
with tempfile.TemporaryDirectory() as tmp:
config = _config(tmp)
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
server, _, _ = build(config)
addr = server.server_address
server.server_close()
self.assertEqual("127.0.0.1", addr[0])
self.assertGreater(addr[1], 0)
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -0,0 +1,38 @@
"""Unit: Config.from_env."""
from __future__ import annotations
import unittest
from pathlib import Path
from bot_bottle.orchestrator.config import Config
class ConfigTest(unittest.TestCase):
def test_defaults(self):
c = Config.from_env({"HOME": "/home/x"})
self.assertEqual("bot-bottle", c.forge_org)
self.assertEqual(1800, c.watchdog_timeout_secs)
self.assertEqual("127.0.0.1", c.webhook_host)
self.assertEqual(8477, c.webhook_port)
self.assertEqual(Path("/home/x/.bot-bottle/forge-queue"), c.queue_dir)
self.assertIsNone(c.db_path)
def test_overrides(self):
c = Config.from_env({
"HOME": "/home/x",
"FORGE_ORG": "agents",
"FORGE_WATCHDOG_TIMEOUT": "60",
"FORGE_GITEA_API": "https://g.example/api/v1",
"FORGE_WEBHOOK_PORT": "9000",
"FORGE_DB_PATH": "/data/bb.db",
})
self.assertEqual("agents", c.forge_org)
self.assertEqual(60, c.watchdog_timeout_secs)
self.assertEqual("https://g.example/api/v1", c.gitea_api)
self.assertEqual(9000, c.webhook_port)
self.assertEqual(Path("/data/bb.db"), c.db_path)
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
"""Unit: webhook payload parsing."""
from __future__ import annotations
import unittest
from bot_bottle.orchestrator.events import parse_event
from bot_bottle.orchestrator.model import CommentCreated, IssueAssigned, PullRequestClosed
_REPO = {"repository": {"name": "bot-bottle", "owner": {"login": "didericis"}}}
class ParseEventTest(unittest.TestCase):
def test_issue_assigned(self):
payload = {
**_REPO,
"action": "assigned",
"issue": {
"number": 17,
"title": "Fix it",
"body": "please",
"assignees": [{"login": "agent-bot"}],
"labels": [{"name": "bot-bottle:implementer"}],
},
}
ev = parse_event("issues", payload)
self.assertIsInstance(ev, IssueAssigned)
assert isinstance(ev, IssueAssigned)
self.assertEqual(("didericis", "bot-bottle", 17), (ev.owner, ev.repo, ev.issue_number))
self.assertEqual(("agent-bot",), ev.assignees)
self.assertEqual(("bot-bottle:implementer",), ev.labels)
def test_issue_non_assigned_ignored(self):
self.assertIsNone(parse_event("issues", {**_REPO, "action": "opened", "issue": {}}))
def test_comment_created(self):
payload = {
**_REPO,
"action": "created",
"issue": {"number": 42, "pull_request": {"x": 1}},
"comment": {"id": 5, "user": {"login": "reviewer"}, "body": "redo"},
}
ev = parse_event("issue_comment", payload)
assert isinstance(ev, CommentCreated)
self.assertEqual(42, ev.issue_number)
self.assertEqual("reviewer", ev.author)
self.assertTrue(ev.is_pull)
def test_pull_request_closed(self):
payload = {**_REPO, "action": "closed", "pull_request": {"number": 8, "merged": True}}
ev = parse_event("pull_request", payload)
assert isinstance(ev, PullRequestClosed)
self.assertEqual(8, ev.pr_number)
self.assertTrue(ev.merged)
def test_pull_request_non_closed_ignored(self):
self.assertIsNone(parse_event("pull_request", {**_REPO, "action": "opened"}))
def test_comment_non_created_action_ignored(self):
payload = {**_REPO, "action": "edited", "issue": {}, "comment": {}}
self.assertIsNone(parse_event("issue_comment", payload))
def test_unknown_kind_ignored(self):
self.assertIsNone(parse_event("push", {**_REPO}))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,75 @@
"""Unit: ForgeState + SqliteForgeStateStore."""
from __future__ import annotations
import unittest
from bot_bottle.contrib.gitea.forge_state import ForgeState, SqliteForgeStateStore
def _state(**kw: object) -> ForgeState:
defaults: dict[str, object] = dict(
owner="alice", repo="myrepo", issue_number=1,
slug="impl-alice-myrepo-1", agent_name="impl",
)
defaults.update(kw)
return ForgeState(**defaults) # type: ignore[arg-type]
class ForgeStateStoreTest(unittest.TestCase):
def setUp(self):
self.store = SqliteForgeStateStore(None)
def test_upsert_and_get(self):
s = _state()
self.store.upsert(s)
got = self.store.get("alice", "myrepo", 1)
assert got is not None
self.assertEqual("impl-alice-myrepo-1", got.slug)
self.assertEqual("impl", got.agent_name)
def test_get_missing(self):
self.assertIsNone(self.store.get("alice", "myrepo", 99))
def test_upsert_replaces(self):
self.store.upsert(_state(status="running"))
self.store.upsert(_state(status="frozen"))
got = self.store.get("alice", "myrepo", 1)
assert got is not None
self.assertEqual("frozen", got.status)
def test_delete(self):
self.store.upsert(_state())
self.store.delete("alice", "myrepo", 1)
self.assertIsNone(self.store.get("alice", "myrepo", 1))
def test_delete_missing_no_error(self):
self.store.delete("alice", "myrepo", 99)
def test_all_sorted(self):
self.store.upsert(_state(owner="z", issue_number=2))
self.store.upsert(_state(owner="a", issue_number=1))
rows = self.store.all()
self.assertEqual(("a", "z"), (rows[0].owner, rows[1].owner))
def test_bottle_names_roundtrip(self):
self.store.upsert(_state(bottle_names=["claude", "dev"]))
got = self.store.get("alice", "myrepo", 1)
assert got is not None
self.assertEqual(["claude", "dev"], got.bottle_names)
def test_pr_number_none_roundtrip(self):
self.store.upsert(_state(pr_number=None))
got = self.store.get("alice", "myrepo", 1)
assert got is not None
self.assertIsNone(got.pr_number)
def test_pr_number_int_roundtrip(self):
self.store.upsert(_state(pr_number=42))
got = self.store.get("alice", "myrepo", 1)
assert got is not None
self.assertEqual(42, got.pr_number)
if __name__ == "__main__":
unittest.main()
+163
View File
@@ -0,0 +1,163 @@
"""Unit: the orchestration lifecycle."""
from __future__ import annotations
import unittest
from typing import cast
from bot_bottle.orchestrator.lifecycle import Orchestrator
from bot_bottle.orchestrator.model import (
STATUS_FROZEN,
STATUS_RUNNING,
CommentCreated,
IssueAssigned,
PullRequestClosed,
)
from bot_bottle.orchestrator.store import InMemoryStateStore
from ._fakes import FakeForge, FakeRunner
def _assigned(
labels: tuple[str, ...] = ("bot-bottle:impl",),
assignees: tuple[str, ...] = ("agent-bot",),
) -> IssueAssigned:
return IssueAssigned(
owner="didericis", repo="bot-bottle", issue_number=17,
title="t", body="the task", assignees=tuple(assignees), labels=tuple(labels),
)
class LifecycleTest(unittest.TestCase):
def setUp(self):
self.forge = FakeForge(members=("agent-bot",))
self.store = InMemoryStateStore()
self.runner = FakeRunner()
self.orch = Orchestrator(
forge=self.forge, store=self.store, runner=self.runner,
org="bot-bottle", gitea_api="https://g/api/v1",
now=lambda: "2026-07-01T00:00:00-04:00",
)
def _record(self):
return self.store.get("didericis", "bot-bottle", 17)
def test_assigned_targeted_launches(self):
self.orch.handle(_assigned())
rec = self._record()
assert rec is not None
self.assertEqual(STATUS_RUNNING, rec.status)
self.assertEqual("impl-didericis-bot-bottle-17", rec.slug)
self.assertEqual("start", self.runner.calls[0][0])
# forge context injected into the child env.
env = cast("dict[str, str]", self.runner.calls[0][5])
self.assertEqual("didericis", env["FORGE_OWNER"])
self.assertEqual("17", env["FORGE_ISSUE_NUMBER"])
def test_untargeted_ignored(self):
self.orch.handle(_assigned(labels=("bug",)))
self.assertIsNone(self._record())
self.assertEqual([], self.runner.calls)
def test_assigned_is_idempotent(self):
self.orch.handle(_assigned())
self.orch.handle(_assigned()) # redelivery
starts = [c for c in self.runner.calls if c[0] == "start"]
self.assertEqual(1, len(starts))
def test_done_signal_freezes(self):
self.orch.handle(_assigned())
self.orch.on_done_signal("didericis", "bot-bottle", 17, "success", "done")
rec = self._record()
assert rec is not None
self.assertEqual(STATUS_FROZEN, rec.status)
self.assertIn(("freeze", "impl-didericis-bot-bottle-17"), self.runner.calls)
def test_done_signal_ignored_when_not_running(self):
# No record yet -> no freeze.
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
self.assertEqual([], self.runner.calls)
def test_comment_on_frozen_resumes(self):
self.orch.handle(_assigned())
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
self.orch.handle(CommentCreated(
owner="didericis", repo="bot-bottle", issue_number=17,
comment_id=1, author="reviewer", body="please redo", is_pull=False,
))
rec = self._record()
assert rec is not None
self.assertEqual(STATUS_RUNNING, rec.status)
self.assertIn(("resume", "impl-didericis-bot-bottle-17", "please redo"),
self.runner.calls)
def test_comment_echo_guard(self):
self.orch.handle(_assigned())
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
rec = self._record()
assert rec is not None
rec.agent_git_user = "agent-bot"
self.store.upsert(rec)
self.orch.handle(CommentCreated(
owner="didericis", repo="bot-bottle", issue_number=17,
comment_id=2, author="agent-bot", body="I finished", is_pull=False,
))
# Still frozen, no resume triggered by the agent's own comment.
self.assertEqual(STATUS_FROZEN, self._record().status) # type: ignore[union-attr]
self.assertNotIn("resume", [c[0] for c in self.runner.calls])
def test_comment_on_running_ignored(self):
self.orch.handle(_assigned()) # running
self.orch.handle(CommentCreated(
owner="didericis", repo="bot-bottle", issue_number=17,
comment_id=1, author="reviewer", body="hi", is_pull=False,
))
self.assertNotIn("resume", [c[0] for c in self.runner.calls])
def test_pr_comment_routes_via_link(self):
self.orch.handle(_assigned())
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
self.orch.link_pr("didericis", "bot-bottle", 17, 42)
# Comment arrives on PR #42 (issue_number == PR number in Gitea).
self.orch.handle(CommentCreated(
owner="didericis", repo="bot-bottle", issue_number=42,
comment_id=9, author="reviewer", body="fix", is_pull=True,
))
self.assertIn(("resume", "impl-didericis-bot-bottle-17", "fix"),
self.runner.calls)
def test_pr_closed_destroys_and_removes(self):
self.orch.handle(_assigned())
self.orch.link_pr("didericis", "bot-bottle", 17, 42)
self.orch.handle(PullRequestClosed(
owner="didericis", repo="bot-bottle", pr_number=42, merged=True,
))
self.assertIn(("destroy", "impl-didericis-bot-bottle-17"), self.runner.calls)
self.assertIsNone(self._record())
def test_comment_on_untracked_issue_ignored(self):
# No record in store and is_pull=False -> _route_comment returns None.
self.orch.handle(CommentCreated(
owner="didericis", repo="bot-bottle", issue_number=99,
comment_id=1, author="reviewer", body="hi", is_pull=False,
))
self.assertEqual([], self.runner.calls)
def test_pr_closed_untracked_pr_ignored(self):
# _find_by_pr finds nothing -> _on_pr_closed exits early.
self.orch.handle(PullRequestClosed(
owner="didericis", repo="bot-bottle", pr_number=999, merged=True,
))
self.assertEqual([], self.runner.calls)
class IsoNowTest(unittest.TestCase):
def test_returns_iso_string(self):
from bot_bottle.orchestrator.lifecycle import _iso_now
ts = _iso_now()
self.assertIsInstance(ts, str)
self.assertIn("T", ts)
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
"""Unit: __main__ CLI entry points (run and status commands)."""
from __future__ import annotations
import io
import unittest
from unittest.mock import patch
from bot_bottle.orchestrator.__main__ import main
from bot_bottle.orchestrator.config import Config
from bot_bottle.orchestrator.model import RunRecord
def _config() -> Config:
return Config.from_env({"HOME": "/tmp"})
class MainRunTest(unittest.TestCase):
def test_run_delegates_to_bootstrap(self):
config = _config()
with patch.object(Config, "from_env", return_value=config), \
patch("bot_bottle.orchestrator.bootstrap.run") as mock_run:
rc = main(["run"])
self.assertEqual(0, rc)
mock_run.assert_called_once_with(config)
def test_run_prints_listen_address_to_stderr(self):
config = _config()
err = io.StringIO()
with patch.object(Config, "from_env", return_value=config), \
patch("bot_bottle.orchestrator.bootstrap.run"), \
patch("sys.stderr", err):
main(["run"])
self.assertIn(str(config.webhook_port), err.getvalue())
class MainStatusTest(unittest.TestCase):
def test_status_empty_store(self):
config = _config()
with patch.object(Config, "from_env", return_value=config), \
patch("bot_bottle.orchestrator.bootstrap.BotBottleStateStore") as MockStore:
MockStore.return_value.all.return_value = []
rc = main(["status"])
self.assertEqual(0, rc)
def test_status_prints_records(self):
config = _config()
rec = RunRecord(
owner="o", repo="r", issue_number=1, slug="my-slug",
agent_name="a", pr_number=7, status="frozen",
)
out = io.StringIO()
with patch.object(Config, "from_env", return_value=config), \
patch("bot_bottle.orchestrator.bootstrap.BotBottleStateStore") as MockStore, \
patch("sys.stdout", out):
MockStore.return_value.all.return_value = [rec]
rc = main(["status"])
self.assertEqual(0, rc)
self.assertIn("my-slug", out.getvalue())
self.assertIn("PR#7", out.getvalue())
def test_status_no_pr_prints_dash(self):
config = _config()
rec = RunRecord(
owner="o", repo="r", issue_number=2, slug="s2",
agent_name="a", pr_number=None, status="running",
)
out = io.StringIO()
with patch.object(Config, "from_env", return_value=config), \
patch("bot_bottle.orchestrator.bootstrap.BotBottleStateStore") as MockStore, \
patch("sys.stdout", out):
MockStore.return_value.all.return_value = [rec]
main(["status"])
self.assertIn("-", out.getvalue())
class MainArgparseTest(unittest.TestCase):
def test_no_command_exits(self):
with self.assertRaises(SystemExit):
main([])
def test_unknown_command_exits(self):
with self.assertRaises(SystemExit):
main(["bogus"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,53 @@
"""Unit: provenance assembly + serialization."""
from __future__ import annotations
import unittest
from bot_bottle.orchestrator.model import RunRecord
from bot_bottle.orchestrator.provenance import build_provenance, ops_from_log, provenance_to_dict
def _record() -> RunRecord:
return RunRecord(
owner="didericis", repo="bot-bottle", issue_number=17,
slug="impl-17", agent_name="impl", bottle_names=["claude"],
last_checkin_at="2026-07-01T00:05:00-04:00",
)
class ProvenanceTest(unittest.TestCase):
def test_ops_from_log(self):
ops = ops_from_log([
{"at": "T1", "op": "read_pr", "target": 5, "detail": "ok"},
{"at": "T2", "op": "signal_done", "target": None, "detail": "success: done"},
])
self.assertEqual(2, len(ops))
self.assertEqual("read_pr", ops[0].op)
self.assertIsNone(ops[1].target)
def test_build_and_serialize(self):
ops = ops_from_log([{"at": "T1", "op": "post_comment", "target": 17, "detail": "ok"}])
prov = build_provenance(
_record(), ops=ops, started_at="2026-07-01T00:00:00-04:00",
finished_at="2026-07-01T00:05:00-04:00", exit_code=0, watchdog_fired=False,
)
d = provenance_to_dict(prov)
self.assertEqual("impl-17", d["slug"])
self.assertEqual("didericis", d["owner"])
self.assertEqual(["claude"], d["bottles"])
self.assertEqual(0, d["exit_code"])
self.assertFalse(d["watchdog_fired"])
self.assertEqual(1, len(d["ops"]))
self.assertEqual("post_comment", d["ops"][0]["op"])
def test_watchdog_flag_serialized(self):
prov = build_provenance(
_record(), ops=(), started_at="", finished_at="",
exit_code=None, watchdog_fired=True,
)
self.assertTrue(provenance_to_dict(prov)["watchdog_fired"])
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""Unit: SubprocessBottleRunner + slugify (injected run fn)."""
from __future__ import annotations
import unittest
from collections.abc import Sequence
from bot_bottle.orchestrator.runner import SubprocessBottleRunner, slugify
class SlugifyTest(unittest.TestCase):
def test_basic(self):
self.assertEqual("impl-didericis-bot-bottle-17",
slugify("impl-didericis-bot-bottle-17"))
def test_collapses_and_strips(self):
self.assertEqual("a-b-c", slugify(" A_B/C!! "))
class SubprocessRunnerTest(unittest.TestCase):
def setUp(self):
self.argvs: list[list[str]] = []
self.envs: list[dict[str, str]] = []
def fake_run(argv: Sequence[str], env: dict[str, str]) -> int:
self.argvs.append(list(argv))
self.envs.append(dict(env))
return 0
self.runner = SubprocessBottleRunner(
cli="/x/cli.py", base_env={"PATH": "/bin"}, python="/py", run=fake_run
)
def test_start_argv_and_env(self):
result = self.runner.start(
agent="impl", bottles=["claude", "dev"], label="impl-r-17",
prompt="do it", forge_env={"FORGE_OWNER": "didericis"},
)
self.assertEqual("impl-r-17", result.slug)
argv = self.argvs[0]
self.assertEqual(["/py", "/x/cli.py", "start", "impl", "--headless",
"--label", "impl-r-17", "--prompt", "do it",
"--bottle", "claude", "--bottle", "dev"], argv)
# forge_env merged over base_env for the child.
self.assertEqual("didericis", self.envs[0]["FORGE_OWNER"])
self.assertEqual("/bin", self.envs[0]["PATH"])
def test_start_no_bottles_omits_flag(self):
self.runner.start(agent="impl", bottles=[], label="l", prompt="p", forge_env={})
self.assertNotIn("--bottle", self.argvs[0])
def test_freeze_calls_commit(self):
self.runner.freeze("slug-1")
self.assertEqual(["/py", "/x/cli.py", "commit", "slug-1"], self.argvs[0])
def test_resume_headless(self):
r = self.runner.resume("slug-1", "address review")
self.assertEqual("slug-1", r.slug)
self.assertEqual(
["/py", "/x/cli.py", "resume", "slug-1", "--headless", "--prompt",
"address review"], self.argvs[0])
def test_destroy_calls_cleanup(self):
code = self.runner.destroy("slug-7")
self.assertEqual(0, code)
self.assertEqual(["/py", "/x/cli.py", "cleanup", "slug-7"], self.argvs[0])
class DefaultRunTest(unittest.TestCase):
def test_calls_subprocess_and_returns_code(self):
from unittest.mock import MagicMock, patch
from bot_bottle.orchestrator.runner import _default_run
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=42)
code = _default_run(["echo", "hi"], {"PATH": "/bin"})
self.assertEqual(42, code)
mock_run.assert_called_once_with(["echo", "hi"], env={"PATH": "/bin"}, check=False)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,75 @@
"""Unit: ScopedForge — read-anywhere / write-scoped access control."""
from __future__ import annotations
import unittest
from bot_bottle.contrib.forge.base import ScopedForge
from ._fakes import FakeForge
class ScopedForgeTest(unittest.TestCase):
def setUp(self):
self.inner = FakeForge()
self.scoped = ScopedForge(
self.inner, assigned_issue=10, assigned_prs=[20, 30]
)
# --- reads always pass through -----------------------------------------
def test_read_issue_allowed_anywhere(self):
for number in (10, 20, 99):
result = self.scoped.read_issue(number)
self.assertEqual(number, result["number"])
def test_read_pr_allowed_anywhere(self):
for number in (10, 20, 99):
result = self.scoped.read_pr(number)
self.assertEqual(number, result["number"])
def test_read_comments_allowed_anywhere(self):
comments = self.scoped.read_comments(99)
self.assertTrue(len(comments) > 0)
def test_is_org_member_passes_through(self):
inner = FakeForge(members=("alice",))
scoped = ScopedForge(inner, assigned_issue=1, assigned_prs=[])
self.assertTrue(scoped.is_org_member("org", "alice"))
self.assertFalse(scoped.is_org_member("org", "bob"))
# --- writes: assigned numbers allowed ----------------------------------
def test_post_comment_on_assigned_issue(self):
self.scoped.post_comment(10, "hi")
self.assertIn((10, "hi"), self.inner.comments)
def test_post_comment_on_assigned_pr(self):
self.scoped.post_comment(20, "lgtm")
self.assertIn((20, "lgtm"), self.inner.comments)
def test_update_description_on_assigned(self):
self.scoped.update_description(30, "updated")
self.assertIn((30, "updated"), self.inner.descriptions)
# --- writes: unassigned numbers denied ---------------------------------
def test_post_comment_denied_for_unassigned(self):
with self.assertRaises(PermissionError):
self.scoped.post_comment(99, "nope")
self.assertEqual([], self.inner.comments)
def test_update_description_denied_for_unassigned(self):
with self.assertRaises(PermissionError):
self.scoped.update_description(99, "nope")
self.assertEqual([], self.inner.descriptions)
def test_error_message_names_number(self):
try:
self.scoped.post_comment(99, "nope")
except PermissionError as exc:
self.assertIn("99", str(exc))
if __name__ == "__main__":
unittest.main()
+204
View File
@@ -0,0 +1,204 @@
"""Unit: forge sidecar dispatch, op log, queue relay, socket server."""
from __future__ import annotations
import dataclasses
import json
import socket
import tempfile
import threading
import unittest
from pathlib import Path
from bot_bottle.orchestrator.sidecar import (
ForgeSidecar,
OpLog,
_jsonable,
drain_done_events,
serve,
write_done_event,
)
from ._fakes import FakeForge
class SidecarDispatchTest(unittest.TestCase):
def setUp(self):
self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
self.forge = FakeForge()
self.log = OpLog(self.tmp / "ops.jsonl", now=lambda: "T")
self.queue = self.tmp / "queue"
self.sc = ForgeSidecar(
forge=self.forge, op_log=self.log, queue_dir=self.queue,
run_key=("o", "r", 17),
)
def test_read_pr_ok_and_logged(self):
resp = self.sc.dispatch("read_pr", {"number": 5})
self.assertTrue(resp["ok"])
self.assertEqual(5, resp["result"]["number"])
self.assertEqual([("read_pr", 5, "ok")],
[(o["op"], o["target"], o["detail"]) for o in self.log.read()])
def test_post_comment_writes_and_logs(self):
resp = self.sc.dispatch("post_comment", {"number": 17, "body": "done"})
self.assertTrue(resp["ok"])
self.assertEqual([(17, "done")], self.forge.comments)
def test_scope_denied_write_returns_error_and_audits_rejection(self):
self.forge.scope_denied.add(999)
resp = self.sc.dispatch("post_comment", {"number": 999, "body": "x"})
self.assertFalse(resp["ok"])
self.assertIn("denied", resp["error"])
# The rejection is recorded in the op log, not just the allows.
self.assertIn("error", self.log.read()[-1]["detail"])
self.assertEqual([], self.forge.comments)
def test_signal_done_queues_event(self):
resp = self.sc.dispatch("signal_done", {"status": "success", "summary": "ok"})
self.assertTrue(resp["ok"])
events = drain_done_events(self.queue)
self.assertEqual(1, len(events))
self.assertEqual(("o", "r", 17, "success"),
(events[0]["owner"], events[0]["repo"],
events[0]["issue_number"], events[0]["status"]))
def test_unknown_method(self):
resp = self.sc.dispatch("delete_repo", {})
self.assertFalse(resp["ok"])
class JsonableTest(unittest.TestCase):
def test_plain_value_passthrough(self):
self.assertEqual(42, _jsonable(42))
self.assertEqual("s", _jsonable("s"))
def test_dataclass_converted_to_dict(self):
@dataclasses.dataclass
class Thing:
x: int
y: str = "hi"
self.assertEqual({"x": 99, "y": "hi"}, _jsonable(Thing(x=99)))
def test_list_recursed(self):
self.assertEqual([1, 2, 3], _jsonable([1, 2, 3]))
def test_list_of_dataclasses(self):
@dataclasses.dataclass
class Item:
v: int
result = _jsonable([Item(v=1), Item(v=2)])
self.assertEqual([{"v": 1}, {"v": 2}], result)
class QueueTest(unittest.TestCase):
def test_drain_removes_events(self):
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
write_done_event(tmp, {"owner": "o", "repo": "r", "issue_number": 1})
self.assertEqual(1, len(drain_done_events(tmp)))
self.assertEqual([], drain_done_events(tmp)) # drained
def test_drain_missing_dir(self):
self.assertEqual([], drain_done_events(Path("/nonexistent/queue")))
def test_drain_skips_corrupted_file(self):
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
(tmp / "done-bad.json").write_text("not json", encoding="utf-8")
events = drain_done_events(tmp)
self.assertEqual([], events)
# The corrupted file is removed by the finally block.
self.assertFalse((tmp / "done-bad.json").exists())
class OpLogReadTest(unittest.TestCase):
def test_read_missing_file_returns_empty(self):
with tempfile.TemporaryDirectory() as tmp:
log = OpLog(Path(tmp) / "sub" / "ops.jsonl")
# File not written yet — read() should return [].
self.assertEqual([], log.read())
class SocketServerTest(unittest.TestCase):
def _make_server(self, tmp: Path):
sock = tmp / "s.sock"
if len(str(sock)) > 100:
self.skipTest("temp socket path too long for AF_UNIX")
sidecar = ForgeSidecar(
forge=FakeForge(), op_log=OpLog(tmp / "ops.jsonl"),
queue_dir=tmp / "q", run_key=("o", "r", 1),
)
return serve(sidecar, sock), sock
def test_round_trip_over_unix_socket(self):
tmp = tempfile.mkdtemp()
sock = Path(tmp) / "s.sock"
if len(str(sock)) > 100: # AF_UNIX path limit; skip on long tmp paths
self.skipTest("temp socket path too long for AF_UNIX")
sidecar = ForgeSidecar(
forge=FakeForge(), op_log=OpLog(Path(tmp) / "ops.jsonl"),
queue_dir=Path(tmp) / "q", run_key=("o", "r", 1),
)
srv = serve(sidecar, sock)
t = threading.Thread(target=srv.handle_request, daemon=True)
t.start()
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect(str(sock))
client.sendall(b'{"method": "read_issue", "params": {"number": 3}}\n')
line = client.makefile().readline()
client.close()
finally:
t.join(timeout=5)
srv.server_close()
resp = json.loads(line)
self.assertTrue(resp["ok"])
self.assertEqual(3, resp["result"]["number"])
def test_handler_invalid_json_returns_error(self):
tmp = Path(tempfile.mkdtemp())
srv, sock = self._make_server(tmp)
t = threading.Thread(target=srv.handle_request, daemon=True)
t.start()
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect(str(sock))
client.sendall(b"not valid json!\n")
line = client.makefile().readline()
client.close()
finally:
t.join(timeout=5)
srv.server_close()
resp = json.loads(line)
self.assertFalse(resp["ok"])
self.assertIn("invalid json", resp["error"])
def test_handler_empty_line_closes_silently(self):
tmp = Path(tempfile.mkdtemp())
srv, sock = self._make_server(tmp)
t = threading.Thread(target=srv.handle_request, daemon=True)
t.start()
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect(str(sock))
client.close() # immediate EOF -> readline() returns b""
finally:
t.join(timeout=5)
srv.server_close()
def test_serve_removes_existing_socket_path(self):
tmp = Path(tempfile.mkdtemp())
sock = tmp / "existing.sock"
if len(str(sock)) > 100:
self.skipTest("temp socket path too long for AF_UNIX")
sock.touch() # pre-existing file at socket path
sidecar = ForgeSidecar(
forge=FakeForge(), op_log=OpLog(tmp / "ops.jsonl"),
queue_dir=tmp / "q", run_key=("o", "r", 1),
)
srv = serve(sidecar, sock) # should unlink the pre-existing file
srv.server_close()
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
"""Unit: InMemoryStateStore."""
from __future__ import annotations
import unittest
from bot_bottle.orchestrator.model import RunRecord
from bot_bottle.orchestrator.store import InMemoryStateStore
def _rec(issue: int, owner: str = "o") -> RunRecord:
return RunRecord(owner=owner, repo="r", issue_number=issue, slug=f"s{issue}",
agent_name="a")
class InMemoryStoreTest(unittest.TestCase):
def setUp(self):
self.store = InMemoryStateStore()
def test_upsert_get(self):
self.store.upsert(_rec(1))
got = self.store.get("o", "r", 1)
assert got is not None
self.assertEqual("s1", got.slug)
def test_get_missing(self):
self.assertIsNone(self.store.get("o", "r", 99))
def test_upsert_replaces(self):
self.store.upsert(_rec(1))
r = _rec(1)
r.slug = "changed"
self.store.upsert(r)
self.assertEqual("changed", self.store.get("o", "r", 1).slug) # type: ignore[union-attr]
self.assertEqual(1, len(self.store.all()))
def test_delete(self):
self.store.upsert(_rec(1))
self.store.delete("o", "r", 1)
self.assertIsNone(self.store.get("o", "r", 1))
def test_all_sorted(self):
self.store.upsert(_rec(2, owner="b"))
self.store.upsert(_rec(1, owner="a"))
self.assertEqual([("a", 1), ("b", 2)],
[(r.owner, r.issue_number) for r in self.store.all()])
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""Unit: targeting (labels + org membership)."""
from __future__ import annotations
import unittest
from bot_bottle.orchestrator.model import IssueAssigned
from bot_bottle.orchestrator.targeting import parse_labels, resolve_target
from ._fakes import FakeForge
def _issue(
assignees: tuple[str, ...] = ("agent-bot",),
labels: tuple[str, ...] = ("bot-bottle:implementer",),
) -> IssueAssigned:
return IssueAssigned(
owner="didericis", repo="bot-bottle", issue_number=17,
title="t", body="b", assignees=tuple(assignees), labels=tuple(labels),
)
class ParseLabelsTest(unittest.TestCase):
def test_agent_label(self):
self.assertEqual(("implementer", None), parse_labels(("bot-bottle:implementer",)))
def test_bottle_override_not_confused_with_agent(self):
agent, bottle = parse_labels(("bot-bottle:impl", "bot-bottle-bottle:dev"))
self.assertEqual(("impl", "dev"), (agent, bottle))
def test_no_agent_label(self):
self.assertEqual((None, None), parse_labels(("bug", "p1")))
class ResolveTargetTest(unittest.TestCase):
def setUp(self):
self.forge = FakeForge(members=("agent-bot",))
def test_targeted(self):
target = resolve_target(_issue(), self.forge, "bot-bottle")
assert target is not None
self.assertEqual("implementer", target.agent_name)
self.assertIsNone(target.bottle_override)
def test_bottle_override(self):
ev = _issue(labels=("bot-bottle:impl", "bot-bottle-bottle:dev"))
target = resolve_target(ev, self.forge, "bot-bottle")
assert target is not None
self.assertEqual("dev", target.bottle_override)
def test_no_label_not_targeted(self):
self.assertIsNone(resolve_target(_issue(labels=("bug",)), self.forge, "bot-bottle"))
def test_non_member_assignee_not_targeted(self):
ev = _issue(assignees=("random-user",))
self.assertIsNone(resolve_target(ev, self.forge, "bot-bottle"))
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
"""Unit: watchdog sweep."""
from __future__ import annotations
import time
import unittest
import unittest.mock
from datetime import datetime, timedelta
from bot_bottle.orchestrator.model import STATUS_FROZEN, STATUS_RUNNING, RunRecord
from bot_bottle.orchestrator.store import InMemoryStateStore
from bot_bottle.orchestrator.watchdog import Watchdog
from ._fakes import FakeRunner
_NOW = datetime(2026, 7, 1, 12, 0, 0).astimezone()
def _record(issue: int, status: str, checkin: str) -> RunRecord:
return RunRecord(
owner="o", repo="r", issue_number=issue, slug=f"s{issue}",
agent_name="a", status=status, last_checkin_at=checkin,
)
class WatchdogSweepTest(unittest.TestCase):
def setUp(self):
self.store = InMemoryStateStore()
self.runner = FakeRunner()
self.wd = Watchdog(store=self.store, runner=self.runner, timeout_secs=1800)
def _status(self, issue: int) -> str:
rec = self.store.get("o", "r", issue)
assert rec is not None
return rec.status
def test_stale_running_is_frozen(self):
stale = (_NOW - timedelta(minutes=31)).isoformat()
self.store.upsert(_record(1, STATUS_RUNNING, stale))
fired = self.wd.sweep(_NOW)
self.assertEqual([1], [r.issue_number for r in fired])
self.assertEqual(STATUS_FROZEN, self._status(1))
self.assertIn(("freeze", "s1"), self.runner.calls)
def test_fresh_running_untouched(self):
fresh = (_NOW - timedelta(minutes=5)).isoformat()
self.store.upsert(_record(2, STATUS_RUNNING, fresh))
self.assertEqual([], self.wd.sweep(_NOW))
self.assertEqual(STATUS_RUNNING, self._status(2))
def test_non_running_ignored(self):
stale = (_NOW - timedelta(hours=2)).isoformat()
self.store.upsert(_record(3, STATUS_FROZEN, stale))
self.assertEqual([], self.wd.sweep(_NOW))
def test_unparseable_checkin_skipped(self):
self.store.upsert(_record(4, STATUS_RUNNING, "not-a-time"))
self.assertEqual([], self.wd.sweep(_NOW))
def test_start_and_stop(self):
# Exercises the daemon-thread start/stop path; stop sets the event
# so the loop's wait returns immediately.
self.wd.start()
self.wd.stop()
def test_loop_sweeps_stale_record(self):
# Patch tick to near-zero so the loop iterates quickly.
stale = (_NOW - timedelta(hours=1)).isoformat()
self.store.upsert(_record(5, STATUS_RUNNING, stale))
with unittest.mock.patch("bot_bottle.orchestrator.watchdog._TICK_SECS", 0.01):
self.wd.start()
time.sleep(0.05) # enough for several iterations at 0.01s tick
self.wd.stop()
rec = self.store.get("o", "r", 5)
assert rec is not None
self.assertEqual(STATUS_FROZEN, rec.status)
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
"""Unit: webhook HTTP surface (signature + routing over a real server)."""
from __future__ import annotations
import hashlib
import hmac
import json
import threading
import unittest
import urllib.request
from urllib.error import HTTPError
from bot_bottle.orchestrator.model import RunRecord
from bot_bottle.orchestrator.store import InMemoryStateStore
from bot_bottle.orchestrator.webhook import WebhookServer, verify_signature
_ISSUE_ASSIGNED = {
"action": "assigned",
"repository": {"name": "bot-bottle", "owner": {"login": "didericis"}},
"issue": {
"number": 17, "title": "t", "body": "b",
"assignees": [{"login": "agent-bot"}],
"labels": [{"name": "bot-bottle:impl"}],
},
}
class _RecordingOrch:
def __init__(self) -> None:
self.events: list[object] = []
def handle(self, event: object) -> None:
self.events.append(event)
class SignatureTest(unittest.TestCase):
def test_verify(self):
secret = b"s3cret"
body = b'{"x":1}'
sig = hmac.new(secret, body, hashlib.sha256).hexdigest()
self.assertTrue(verify_signature(secret, body, sig))
self.assertFalse(verify_signature(secret, body, "deadbeef"))
class WebhookServerTest(unittest.TestCase):
# _serve is the per-test setup; attributes are assigned there.
# pylint: disable=attribute-defined-outside-init
def _serve(self, **kwargs: object) -> None:
self.orch = _RecordingOrch()
kwargs.setdefault("store", InMemoryStateStore())
self.server = WebhookServer(
("127.0.0.1", 0), orchestrator=self.orch, **kwargs, # type: ignore[arg-type]
)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.addCleanup(self._shutdown)
def _shutdown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
def _post(
self, path: str, body: bytes, headers: dict[str, str] | None = None
) -> tuple[int, dict[str, object]]:
req = urllib.request.Request(
f"http://127.0.0.1:{self.port}{path}", data=body, method="POST",
headers=headers or {},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, json.loads(resp.read())
def _get(self, path: str) -> tuple[int, dict[str, object]]:
with urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) as r:
return r.status, json.loads(r.read())
def test_webhook_dispatches(self):
self._serve()
body = json.dumps(_ISSUE_ASSIGNED).encode()
status, payload = self._post("/webhook", body, {"X-Gitea-Event": "issues"})
self.assertEqual(200, status)
self.assertTrue(payload["handled"])
self.assertEqual(1, len(self.orch.events))
def test_unhandled_event_ok_but_not_handled(self):
self._serve()
body = json.dumps({"action": "push"}).encode()
_status, payload = self._post("/webhook", body, {"X-Gitea-Event": "push"})
self.assertFalse(payload["handled"])
self.assertEqual([], self.orch.events)
def test_invalid_json_400(self):
self._serve()
with self.assertRaises(HTTPError) as ctx:
self._post("/webhook", b"{not json", {"X-Gitea-Event": "issues"})
self.assertEqual(400, ctx.exception.code)
def test_bad_signature_rejected(self):
self._serve(secret=b"sekret")
body = json.dumps(_ISSUE_ASSIGNED).encode()
with self.assertRaises(HTTPError) as ctx:
self._post("/webhook", body,
{"X-Gitea-Event": "issues", "X-Gitea-Signature": "deadbeef"})
self.assertEqual(401, ctx.exception.code)
self.assertEqual([], self.orch.events)
def test_good_signature_accepted(self):
self._serve(secret=b"sekret")
body = json.dumps(_ISSUE_ASSIGNED).encode()
sig = hmac.new(b"sekret", body, hashlib.sha256).hexdigest()
status, _payload = self._post(
"/webhook", body, {"X-Gitea-Event": "issues", "X-Gitea-Signature": sig})
self.assertEqual(200, status)
self.assertEqual(1, len(self.orch.events))
def test_healthz(self):
self._serve()
self.assertEqual(200, self._get("/healthz")[0])
def test_unknown_path_404(self):
self._serve()
with self.assertRaises(HTTPError) as ctx:
self._post("/nope", b"{}", {"X-Gitea-Event": "issues"})
self.assertEqual(404, ctx.exception.code)
def test_provenance_returns_record_and_ops(self):
store = InMemoryStateStore()
store.upsert(RunRecord(owner="didericis", repo="bot-bottle", issue_number=17,
slug="impl-17", agent_name="impl", bottle_names=["claude"]))
def reader(rec: object) -> list[dict[str, object]]: # pylint: disable=unused-argument
return [{"at": "T", "op": "post_comment", "target": 17, "detail": "ok"}]
self._serve(store=store, op_log_reader=reader)
status, payload = self._get("/provenance?owner=didericis&repo=bot-bottle&issue=17")
self.assertEqual(200, status)
self.assertEqual("impl-17", payload["slug"])
self.assertEqual(1, len(payload["ops"])) # type: ignore[arg-type]
def test_provenance_missing_params_400(self):
self._serve()
with self.assertRaises(HTTPError) as ctx:
self._get("/provenance?owner=didericis")
self.assertEqual(400, ctx.exception.code)
def test_provenance_unknown_run_404(self):
self._serve()
with self.assertRaises(HTTPError) as ctx:
self._get("/provenance?owner=x&repo=y&issue=1")
self.assertEqual(404, ctx.exception.code)
def test_unknown_get_path_404(self):
self._serve()
with self.assertRaises(HTTPError) as ctx:
self._get("/nope")
self.assertEqual(404, ctx.exception.code)
if __name__ == "__main__":
unittest.main()
+67 -5
View File
@@ -9,11 +9,15 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
CLAUDE_HOST_CREDENTIAL_HOSTS,
CODEX_HOST_CREDENTIAL_HOSTS, CODEX_HOST_CREDENTIAL_HOSTS,
build_agent_provision_plan, build_agent_provision_plan,
prompt_args, prompt_args,
) )
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF from bot_bottle.egress import (
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
CODEX_HOST_CREDENTIAL_TOKEN_REF,
)
def _jwt(exp: int) -> str: def _jwt(exp: int) -> str:
@@ -35,10 +39,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
) )
config = Path(tmp, "codex-config.toml").read_text() config = Path(tmp, "codex-config.toml").read_text()
self.assertEqual("codex", plan.template) self.assertEqual("codex", plan.template)
self.assertEqual( self.assertEqual("codex", plan.command)
"/home/node/.codex/packages/standalone/current/bin/codex",
plan.command,
)
self.assertEqual("read_prompt_file", plan.prompt_mode) self.assertEqual("read_prompt_file", plan.prompt_mode)
self.assertEqual("/tmp/Dockerfile.codex", plan.dockerfile) self.assertEqual("/tmp/Dockerfile.codex", plan.dockerfile)
self.assertEqual( self.assertEqual(
@@ -292,6 +293,67 @@ class TestAgentProviderRuntime(unittest.TestCase):
) )
self.assertEqual({}, plan.provisioned_env) self.assertEqual({}, plan.provisioned_env)
def test_claude_forward_host_credentials_populates_egress_route(self):
access_token = "sk-ant-oat01-test-key"
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-claude"
cred_dir = home / ".claude"
cred_dir.mkdir(parents=True)
(cred_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {"accessToken": access_token},
}))
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"HOME": str(home)},
)
self.assertEqual(1, len(plan.egress_routes))
route = plan.egress_routes[0]
self.assertIn(route.host, CLAUDE_HOST_CREDENTIAL_HOSTS)
self.assertEqual("Bearer", route.auth_scheme)
self.assertEqual(CLAUDE_HOST_CREDENTIAL_TOKEN_REF, route.token_ref)
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
def test_claude_forward_host_credentials_populates_provisioned_env(self):
access_token = "sk-ant-oat01-test-key"
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-claude"
cred_dir = home / ".claude"
cred_dir.mkdir(parents=True)
(cred_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {"accessToken": access_token},
}))
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"HOME": str(home)},
)
self.assertEqual(
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
plan.provisioned_env,
)
def test_claude_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False,
)
self.assertEqual({}, plan.provisioned_env)
def test_pi_plan_writes_default_ollama_models(self): def test_pi_plan_writes_default_ollama_models(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = build_agent_provision_plan( plan = build_agent_provision_plan(
-400
View File
@@ -1,400 +0,0 @@
"""Unit: bot_bottle public Python API (bot_bottle/__init__.py surface).
Covers start_headless, resume_headless, freeze, and destroy the four
operations the bot-bottle-orchestrator's ProgrammaticBottleRunner uses.
All I/O is stubbed so no container is created.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless
from bot_bottle.log import Die
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_manifest(agent_name: str = "implementer", bottle_name: str = "claude"):
manifest = MagicMock()
manifest.agents = {agent_name: MagicMock(bottle=bottle_name)}
manifest.all_agent_names = [agent_name]
manifest.all_bottle_names = [bottle_name]
manifest.home_md = None # eager mode — _peek_agent_bottle uses agents dict
manifest.require_agent = MagicMock(return_value=None)
return manifest
def _metadata(
slug: str = "implementer-abc12",
agent_name: str = "implementer",
backend: str = "docker",
):
md = MagicMock()
md.identity = slug
md.agent_name = agent_name
md.cwd = "/repo"
md.copy_cwd = False
md.bottle_names = ["claude"]
md.backend = backend
return md
# ---------------------------------------------------------------------------
# start_headless
# ---------------------------------------------------------------------------
class TestCompatibilityImports(unittest.TestCase):
def test_legacy_api_module_reexports_public_runner_api(self):
from bot_bottle import api
from bot_bottle.bottle import runner
self.assertIs(api.BottleError, runner.BottleError)
self.assertIs(api.start_headless, runner.start_headless)
class TestStartHeadless(unittest.TestCase):
def setUp(self) -> None:
self._manifest = _make_manifest()
patch("bot_bottle.bottle.runner.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch = patch(
"bot_bottle.bottle.runner._launch_bottle", return_value=("implementer-abc12", 0)
).start()
patch(
"bot_bottle.bottle.runner._uniquify_label_headless", side_effect=str
).start()
self.addCleanup(patch.stopall)
def _spec(self):
self._launch.assert_called_once()
return self._launch.call_args[0][0]
def test_returns_slug_on_success(self):
slug = start_headless("implementer", prompt="Do it")
self.assertEqual("implementer-abc12", slug)
def test_passes_assume_yes_and_prompt(self):
start_headless("implementer", prompt="Do it")
kwargs = self._launch.call_args[1]
self.assertTrue(kwargs["assume_yes"])
self.assertEqual("Do it", kwargs["headless_prompt_text"])
def test_explicit_bottles_forwarded(self):
start_headless("implementer", prompt="Do it", bottles=["dev", "claude"])
self.assertEqual(("dev", "claude"), self._spec().bottle_names)
def test_default_bottle_resolved_from_manifest(self):
start_headless("implementer", prompt="Do it")
self.assertEqual(("claude",), self._spec().bottle_names)
def test_forge_env_on_spec(self):
env = {"FORGE_GITEA_API": "https://gitea.example.com/api/v1", "FORGE_OWNER": "acme"}
start_headless("implementer", prompt="Do it", forge_env=env)
self.assertEqual(env, self._spec().forge_env)
def test_no_forge_env_defaults_to_empty_dict(self):
start_headless("implementer", prompt="Do it")
self.assertEqual({}, self._spec().forge_env)
def test_nonzero_exit_raises_bottle_error(self):
self._launch.return_value = ("implementer-abc12", 1)
with self.assertRaises(BottleError) as ctx:
start_headless("implementer", prompt="Do it")
self.assertEqual(1, ctx.exception.exit_code)
def test_no_default_bottle_raises_bottle_error(self):
manifest = _make_manifest(bottle_name="")
with patch("bot_bottle.bottle.runner.ManifestIndex.resolve", return_value=manifest):
with self.assertRaises(BottleError):
start_headless("implementer", prompt="Do it")
self._launch.assert_not_called()
def test_manifest_error_in_resolve_raises_bottle_error(self):
from bot_bottle.manifest import ManifestError
patch(
"bot_bottle.bottle.runner.ManifestIndex.resolve", side_effect=ManifestError("bad")
).start()
with self.assertRaises(BottleError):
start_headless("implementer", prompt="Do it")
self._launch.assert_not_called()
def test_die_from_launch_bottle_raises_bottle_error(self):
self._launch.side_effect = Die(3, "backend exploded")
with self.assertRaises(BottleError) as ctx:
start_headless("implementer", prompt="Do it")
self.assertEqual(3, ctx.exception.exit_code)
def test_backend_name_forwarded(self):
start_headless("implementer", prompt="Do it", backend_name="docker")
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
def test_label_forwarded_to_spec(self):
start_headless("implementer", prompt="Do it", label="nightly")
self.assertEqual("nightly", self._spec().label)
def test_color_forwarded_to_spec(self):
start_headless("implementer", prompt="Do it", color="green")
self.assertEqual("green", self._spec().color)
# ---------------------------------------------------------------------------
# resume_headless
# ---------------------------------------------------------------------------
class TestResumeHeadless(unittest.TestCase):
def setUp(self) -> None:
self._md = _metadata()
patch("bot_bottle.bottle.runner.read_metadata", return_value=self._md).start()
manifest = _make_manifest()
patch("bot_bottle.bottle.runner.ManifestIndex.resolve", return_value=manifest).start()
self._launch = patch(
"bot_bottle.bottle.runner._launch_bottle", return_value=("implementer-abc12", 0)
).start()
self.addCleanup(patch.stopall)
def _spec(self):
self._launch.assert_called_once()
return self._launch.call_args[0][0]
def test_passes_assume_yes_and_prompt(self):
resume_headless("implementer-abc12", prompt="Address review")
kwargs = self._launch.call_args[1]
self.assertTrue(kwargs["assume_yes"])
self.assertEqual("Address review", kwargs["headless_prompt_text"])
def test_identity_set_on_spec(self):
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual("implementer-abc12", self._spec().identity)
def test_forge_env_on_spec(self):
env = {"FORGE_ISSUE_NUMBER": "42"}
resume_headless("implementer-abc12", prompt="Prompt", forge_env=env)
self.assertEqual(env, self._spec().forge_env)
def test_missing_state_raises_bottle_error(self):
with patch("bot_bottle.bottle.runner.read_metadata", return_value=None):
with self.assertRaises(BottleError):
resume_headless("no-such-abc12", prompt="Prompt")
self._launch.assert_not_called()
def test_nonzero_exit_raises_bottle_error(self):
self._launch.return_value = ("implementer-abc12", 2)
with self.assertRaises(BottleError) as ctx:
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual(2, ctx.exception.exit_code)
def test_manifest_error_in_resolve_raises_bottle_error(self):
from bot_bottle.manifest import ManifestError
patch(
"bot_bottle.bottle.runner.ManifestIndex.resolve", side_effect=ManifestError("bad")
).start()
with self.assertRaises(BottleError):
resume_headless("implementer-abc12", prompt="Prompt")
self._launch.assert_not_called()
def test_die_from_launch_bottle_raises_bottle_error(self):
self._launch.side_effect = Die(5, "resume failed")
with self.assertRaises(BottleError) as ctx:
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual(5, ctx.exception.exit_code)
def test_backend_from_metadata_when_not_supplied(self):
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
def test_explicit_backend_overrides_metadata(self):
resume_headless(
"implementer-abc12", prompt="Prompt", backend_name="smolmachines"
)
self.assertEqual("smolmachines", self._launch.call_args[1]["backend_name"])
# ---------------------------------------------------------------------------
# freeze
# ---------------------------------------------------------------------------
class TestFreeze(unittest.TestCase):
def setUp(self) -> None:
patch("bot_bottle.bottle.runner.read_metadata", return_value=_metadata()).start()
self._freezer = MagicMock()
self._get_freezer = patch(
"bot_bottle.bottle.runner.get_freezer", return_value=self._freezer
).start()
self.addCleanup(patch.stopall)
def test_calls_commit_slug(self):
freeze("implementer-abc12")
self._freezer.commit_slug.assert_called_once_with("implementer-abc12")
def test_backend_from_metadata_when_not_supplied(self):
freeze("implementer-abc12")
self._get_freezer.assert_called_once_with("docker")
def test_explicit_backend_used(self):
freeze("implementer-abc12", backend_name="smolmachines")
self._get_freezer.assert_called_once_with("smolmachines")
def test_commit_cancelled_raises_bottle_error(self):
from bot_bottle.backend.freeze import CommitCancelled
self._freezer.commit_slug.side_effect = CommitCancelled("declined")
with self.assertRaises(BottleError):
freeze("implementer-abc12")
def test_die_from_freezer_raises_bottle_error(self):
self._freezer.commit_slug.side_effect = Die(2, "commit exploded")
with self.assertRaises(BottleError) as ctx:
freeze("implementer-abc12")
self.assertEqual(2, ctx.exception.exit_code)
# ---------------------------------------------------------------------------
# destroy
# ---------------------------------------------------------------------------
class TestDestroy(unittest.TestCase):
def setUp(self) -> None:
patch("bot_bottle.bottle.runner.read_metadata", return_value=_metadata()).start()
self._dd = patch("bot_bottle.bottle.runner._destroy_docker").start()
patch("bot_bottle.bottle.runner.clear_preserve_marker").start()
self._cleanup = patch("bot_bottle.bottle.runner.cleanup_state").start()
self.addCleanup(patch.stopall)
def test_docker_backend_calls_destroy_docker(self):
destroy("implementer-abc12")
self._dd.assert_called_once_with("implementer-abc12")
def test_state_dir_always_cleaned(self):
destroy("implementer-abc12")
self._cleanup.assert_called_once_with("implementer-abc12")
def test_smolmachines_backend_calls_destroy_smolmachines(self):
patch(
"bot_bottle.bottle.runner.read_metadata",
return_value=_metadata(backend="smolmachines"),
).start()
ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start()
destroy("implementer-abc12")
ds.assert_called_once_with("implementer-abc12")
self._dd.assert_not_called()
def test_other_backend_skips_docker_and_smolmachines(self):
patch(
"bot_bottle.bottle.runner.read_metadata",
return_value=_metadata(backend="macos-container"),
).start()
ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start()
destroy("implementer-abc12")
self._dd.assert_not_called()
ds.assert_not_called()
self._cleanup.assert_called_once_with("implementer-abc12")
def test_missing_metadata_defaults_to_docker(self):
patch("bot_bottle.bottle.runner.read_metadata", return_value=None).start()
destroy("no-state-abc12")
self._dd.assert_called_once_with("no-state-abc12")
def test_explicit_backend_overrides_metadata(self):
ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start()
destroy("implementer-abc12", backend_name="smolmachines")
ds.assert_called_once_with("implementer-abc12")
self._dd.assert_not_called()
def test_die_from_backend_raises_bottle_error(self):
self._dd.side_effect = Die(1, "compose failed")
with self.assertRaises(BottleError):
destroy("implementer-abc12")
# ---------------------------------------------------------------------------
# _destroy_docker (helper)
# ---------------------------------------------------------------------------
class TestDestroyDocker(unittest.TestCase):
def setUp(self) -> None:
self._compose_down = patch(
"bot_bottle.backend.docker.compose.compose_down"
).start()
self._compose_project_name = patch(
"bot_bottle.backend.docker.compose.compose_project_name",
return_value="bb-proj",
).start()
self._state_dir = patch(
"bot_bottle.bottle.state.bottle_state_dir",
return_value=Path("/fake/state"),
).start()
self.addCleanup(patch.stopall)
def _run(self, exists: bool) -> None:
fake_file = MagicMock()
fake_file.exists.return_value = exists
with patch(
"bot_bottle.backend.docker.compose.compose_file_path",
return_value=fake_file,
):
from bot_bottle.bottle.runner import _destroy_docker
_destroy_docker("slug-1")
def test_calls_compose_down_when_file_exists(self) -> None:
self._run(exists=True)
self._compose_down.assert_called_once()
def test_noop_when_compose_file_absent(self) -> None:
self._run(exists=False)
self._compose_down.assert_not_called()
# ---------------------------------------------------------------------------
# _destroy_smolmachines (helper)
# ---------------------------------------------------------------------------
class TestDestroySmolmachines(unittest.TestCase):
def setUp(self) -> None:
self._run = patch("subprocess.run").start()
self._run.return_value = MagicMock(returncode=0, stderr="", stdout="")
self.addCleanup(patch.stopall)
def _call(self) -> None:
from bot_bottle.bottle.runner import _destroy_smolmachines
_destroy_smolmachines("slug-7")
def test_issues_stop_then_delete(self) -> None:
self._call()
self.assertEqual(2, self._run.call_count)
first_argv = self._run.call_args_list[0][0][0]
self.assertIn("stop", first_argv)
second_argv = self._run.call_args_list[1][0][0]
self.assertIn("delete", second_argv)
def test_nonzero_delete_does_not_raise(self) -> None:
self._run.side_effect = [
MagicMock(returncode=0),
MagicMock(returncode=1, stderr="not found", stdout=""),
]
self._call() # must not raise
# ---------------------------------------------------------------------------
# public surface exported from bot_bottle.__init__
# ---------------------------------------------------------------------------
class TestPublicSurface(unittest.TestCase):
def test_importable_from_package(self):
import bot_bottle
for name in ("BottleError", "start_headless", "resume_headless", "freeze", "destroy"):
self.assertTrue(hasattr(bot_bottle, name), f"missing: {name}")
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More