Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7278ee1157 | |||
| bdd352570b | |||
| f0d27863c2 | |||
| 71699b3ecd | |||
| 57290da1e8 | |||
| df1f0e8f70 | |||
| 314dc03b0d | |||
| 06025687ed |
+2
-8
@@ -18,7 +18,7 @@
|
||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||
# /git-gate/creds/* docker-cp'd at start time
|
||||
# /git/* bare repos, populated at runtime
|
||||
# /run/supervise/bot-bottle.db bind-mounted at run time
|
||||
# /run/supervise/queue/ bind-mounted at run time
|
||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||
#
|
||||
# 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/dlp_detectors.py /app/dlp_detectors.py
|
||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
COPY bot_bottle/migrations.py /app/migrations.py
|
||||
COPY bot_bottle/db_store.py /app/db_store.py
|
||||
COPY bot_bottle/supervise_types.py /app/supervise_types.py
|
||||
COPY bot_bottle/queue_store.py /app/queue_store.py
|
||||
COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||
COPY bot_bottle/store_manager.py /app/store_manager.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||
@@ -87,7 +81,7 @@ RUN mkdir -p \
|
||||
/etc/git-gate \
|
||||
/git-gate/creds \
|
||||
/git \
|
||||
/run/supervise \
|
||||
/run/supervise/queue \
|
||||
/home/mitmproxy/.mitmproxy
|
||||
|
||||
# Documentation only — the compose renderer publishes whichever
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# bot-bottle
|
||||
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||
[](https://coverage.readthedocs.io/)
|
||||
[](https://coverage.readthedocs.io/)
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||
|
||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||
@@ -25,7 +25,7 @@
|
||||
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
||||
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. 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`.
|
||||
|
||||
## Architecture
|
||||
@@ -71,26 +71,10 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
|
||||
|
||||
## Quickstart
|
||||
|
||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus `smolvm` (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.
|
||||
|
||||
### smolmachines on Linux
|
||||
|
||||
The smolmachines backend runs on Linux as well as macOS. On Linux, `smolvm`/libkrun use KVM, so the host needs:
|
||||
|
||||
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
||||
- **`smolvm`** on `PATH`: `curl -sSL https://smolmachines.com/install.sh | sh`.
|
||||
- **Docker** for the sidecar bundle and image build, same as macOS.
|
||||
|
||||
Per-bottle isolation works the same as macOS without any `ifconfig`/sudo step — all of `127.0.0.0/8` is already loopback on Linux, so each bottle's sidecar bundle is published on its own `127.0.0.<N>` and TSI's allowlist is scoped to that `/32`.
|
||||
|
||||
```sh
|
||||
BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>
|
||||
```
|
||||
|
||||
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. If you run bottles from a Gitea Actions runner, use a `host`-label runner so Docker, `smolvm`, and `/dev/kvm` are all reachable from the job. `smolvm` isn't in nixpkgs — install the release binary (pin the version) and put it on the runner's `PATH`.
|
||||
|
||||
```sh
|
||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||
```
|
||||
|
||||
@@ -45,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||
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[
|
||||
"append_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 .log import die
|
||||
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
||||
# Ensure the target directory exists. smolvm's pack step may not
|
||||
# preserve the empty /usr/local/share/ca-certificates/ directory
|
||||
# on Linux; mkdir -p is idempotent and safe for all backends.
|
||||
bottle.exec("mkdir -p /usr/local/share/ca-certificates", user="root")
|
||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||
r = bottle.exec(
|
||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||
|
||||
@@ -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"]
|
||||
@@ -36,10 +36,10 @@ import os
|
||||
import shlex
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, Generic, Sequence, TypeVar
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||
from ..egress import EgressPlan
|
||||
@@ -75,12 +75,6 @@ class BottleSpec:
|
||||
# Ordered bottle names selected at launch (issue #269). When non-empty
|
||||
# they are merged in order and replace the agent's `bottle:` field.
|
||||
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
|
||||
# Image startup policy. "fresh" preserves the normal build path;
|
||||
# "cached" reuses the current local image/artifact without rebuilding.
|
||||
image_policy: str = "fresh"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -306,13 +300,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
|
||||
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_agent_provider = manifest_bottle.agent_provider
|
||||
agent_provider = get_provider(manifest_agent_provider.template)
|
||||
@@ -435,25 +422,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
prompt file, Dockerfile path, and guest home all live on
|
||||
`agent_provision_plan` — the source of truth."""
|
||||
|
||||
@contextmanager
|
||||
def launch(
|
||||
self, plan: PlanT, *, skip_stale: bool = False
|
||||
) -> Generator[Bottle, None, None]:
|
||||
"""Template: optionally check for stale cached images, then delegate
|
||||
to `_launch_impl`. Pass `skip_stale=True` to bypass the stale check
|
||||
(used by the interactive CLI after the operator confirms)."""
|
||||
if not skip_stale:
|
||||
self._image_stale_checks(plan)
|
||||
with self._launch_impl(plan) as bottle:
|
||||
yield bottle
|
||||
|
||||
def _image_stale_checks(self, plan: PlanT) -> None:
|
||||
"""Raise StaleImageError if any cached image used by this plan is stale.
|
||||
No-op default; backends override to call the shared `check_stale*`
|
||||
helpers on their image/artifact timestamps."""
|
||||
|
||||
@abstractmethod
|
||||
def _launch_impl(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||
|
||||
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
||||
|
||||
@@ -85,11 +85,8 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
def _image_stale_checks(self, plan: DockerBottlePlan) -> None:
|
||||
_launch.stale_checks(plan)
|
||||
|
||||
@contextmanager
|
||||
def _launch_impl(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from ...egress import (
|
||||
from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
from ...supervise import (
|
||||
DB_PATH_IN_CONTAINER,
|
||||
QUEUE_DIR_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
)
|
||||
@@ -163,15 +163,16 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
if sp is not None:
|
||||
env += [
|
||||
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}",
|
||||
]
|
||||
volumes.append({
|
||||
"type": "bind",
|
||||
"source": str(sp.db_path),
|
||||
"target": DB_PATH_IN_CONTAINER,
|
||||
"source": str(sp.queue_dir),
|
||||
"target": QUEUE_DIR_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
|
||||
internal_aliases = [EGRESS_HOSTNAME]
|
||||
if gp.upstreams:
|
||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||
@@ -180,6 +181,10 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": SIDECAR_BUNDLE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
},
|
||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||
"networks": {
|
||||
"internal": {"aliases": internal_aliases},
|
||||
@@ -188,11 +193,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"environment": env,
|
||||
"volumes": volumes,
|
||||
}
|
||||
if plan.spec.image_policy != "cached":
|
||||
service["build"] = {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@ from ...git_gate import (
|
||||
provision_git_gate_dynamic_keys,
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
from ...image_cache import check_stale
|
||||
from ...log import die, info, warn
|
||||
from ...log import info, warn
|
||||
from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
@@ -64,7 +63,6 @@ from .compose import (
|
||||
write_compose_file,
|
||||
)
|
||||
from .egress import egress_tls_init
|
||||
from .sidecar_bundle import SIDECAR_BUNDLE_IMAGE
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
@@ -102,26 +100,12 @@ def launch(
|
||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||
# up` via the renderer's `build:` directives.
|
||||
committed = read_committed_image(plan.slug)
|
||||
cached_policy = plan.spec.image_policy == "cached"
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
elif cached_policy:
|
||||
if not docker_mod.image_exists(plan.image):
|
||||
die(
|
||||
f"cached agent image {plan.image!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
if not docker_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
||||
die(
|
||||
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
||||
else:
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
@@ -223,21 +207,3 @@ def launch(
|
||||
yield bottle
|
||||
finally:
|
||||
teardown()
|
||||
|
||||
|
||||
def stale_checks(plan: DockerBottlePlan) -> None:
|
||||
"""Raise StaleImageError if a cached image is older than the configured
|
||||
threshold. Only runs when image_policy is 'cached'. Called by the backend
|
||||
class's _image_stale_checks before _launch_impl starts any resources."""
|
||||
if plan.spec.image_policy != "cached":
|
||||
return
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
check_stale(f"agent image {committed!r}", docker_mod.image_created_at(committed))
|
||||
return
|
||||
for label, ref in [
|
||||
("agent image", plan.image),
|
||||
("sidecar image", SIDECAR_BUNDLE_IMAGE),
|
||||
]:
|
||||
if docker_mod.image_exists(ref):
|
||||
check_stale(f"{label} {ref!r}", docker_mod.image_created_at(ref))
|
||||
|
||||
@@ -4,7 +4,6 @@ existence, and building images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -187,45 +186,6 @@ def image_id(ref: str) -> str:
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def image_created_at(ref: str) -> datetime:
|
||||
"""Return Docker's image Created timestamp as an aware UTC datetime."""
|
||||
r = subprocess.run(
|
||||
["docker", "image", "inspect", "--format", "{{.Created}}", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
die(
|
||||
f"docker image inspect for {ref!r} failed: "
|
||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
raw = r.stdout.strip()
|
||||
try:
|
||||
return _parse_docker_timestamp(raw)
|
||||
except ValueError:
|
||||
die(f"docker image inspect for {ref!r} returned invalid Created timestamp: {raw!r}")
|
||||
|
||||
|
||||
def _parse_docker_timestamp(raw: str) -> datetime:
|
||||
text = raw.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
dot = text.find(".")
|
||||
if dot != -1:
|
||||
tz_plus = text.find("+", dot)
|
||||
tz_minus = text.find("-", dot)
|
||||
tz_candidates = [pos for pos in (tz_plus, tz_minus) if pos != -1]
|
||||
if tz_candidates:
|
||||
tz_pos = min(tz_candidates)
|
||||
frac = text[dot + 1:tz_pos]
|
||||
text = text[:dot + 1] + frac[:6].ljust(6, "0") + text[tz_pos:]
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def save(ref: str, output: str) -> None:
|
||||
"""`docker save REF -o OUTPUT`. Writes a tarball of the image
|
||||
layers + manifest to the host path. Used by smolmachines
|
||||
|
||||
@@ -67,11 +67,8 @@ class MacosContainerBottleBackend(
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
def _image_stale_checks(self, plan: MacosContainerBottlePlan) -> None:
|
||||
_launch.stale_checks(plan)
|
||||
|
||||
@contextmanager
|
||||
def _launch_impl(
|
||||
def launch(
|
||||
self, plan: MacosContainerBottlePlan
|
||||
) -> Generator[MacosContainerBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
|
||||
@@ -32,9 +32,8 @@ from ...git_gate import (
|
||||
provision_git_gate_dynamic_keys,
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
from ...image_cache import check_stale
|
||||
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 ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
||||
from ..docker.git_gate import (
|
||||
@@ -150,53 +149,29 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
|
||||
|
||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
cached = plan.spec.image_policy == "cached"
|
||||
container_mod.build_image(
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
_REPO_DIR,
|
||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||
)
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and container_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
if not cached:
|
||||
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
agent_provision=dataclasses.replace(
|
||||
plan.agent_provision,
|
||||
image=committed,
|
||||
),
|
||||
)
|
||||
if cached:
|
||||
if not container_mod.image_exists(plan.image):
|
||||
die(
|
||||
f"cached agent image {plan.image!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
if not container_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
||||
die(
|
||||
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
||||
return plan
|
||||
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
container_mod.build_image(
|
||||
plan.image,
|
||||
_REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def stale_checks(plan: MacosContainerBottlePlan) -> None:
|
||||
"""Raise StaleImageError if a cached image is older than the configured
|
||||
threshold. Only runs when image_policy is 'cached'. Called by the backend
|
||||
class's _image_stale_checks before _launch_impl starts any resources."""
|
||||
if plan.spec.image_policy != "cached":
|
||||
return
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and container_mod.image_exists(committed):
|
||||
check_stale(f"agent image {committed!r}", container_mod.image_created_at(committed))
|
||||
return
|
||||
for label, ref in [
|
||||
("agent image", plan.image),
|
||||
("sidecar image", SIDECAR_BUNDLE_IMAGE),
|
||||
]:
|
||||
if container_mod.image_exists(ref):
|
||||
check_stale(f"{label} {ref!r}", container_mod.image_created_at(ref))
|
||||
|
||||
|
||||
def _create_networks(
|
||||
internal_network: str,
|
||||
egress_network: str,
|
||||
@@ -404,7 +379,7 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||
if plan.supervise_plan is not None:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
return tuple(env)
|
||||
@@ -430,15 +405,7 @@ def _sidecar_mounts(
|
||||
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
# `container run --mount type=bind` only accepts directory
|
||||
# sources (a file source fails with "is not a directory") —
|
||||
# mount db_path's dedicated parent dir instead of the file
|
||||
# itself, same as the CA/routes mounts above.
|
||||
mounts.append((
|
||||
str(sp.db_path.parent),
|
||||
str(Path(DB_PATH_IN_CONTAINER).parent),
|
||||
False,
|
||||
))
|
||||
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||
|
||||
return tuple(mounts)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from ...log import die, info
|
||||
@@ -459,39 +458,6 @@ def image_id(ref: str) -> str:
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def image_created_at(ref: str) -> datetime:
|
||||
"""Return the image creation timestamp as an aware UTC datetime.
|
||||
|
||||
Parses the `created` field from `container image inspect` JSON output."""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "image", "inspect", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container image inspect for {ref!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
|
||||
if isinstance(data, list) and data:
|
||||
data = data[0]
|
||||
if isinstance(data, dict):
|
||||
value = data.get("created") or data.get("Created")
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
ts = value.rstrip("Z")
|
||||
return datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
die(f"container image inspect for {ref!r} did not include a creation timestamp")
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def save(ref: str, output: str) -> None:
|
||||
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
|
||||
|
||||
|
||||
@@ -77,11 +77,8 @@ class SmolmachinesBottleBackend(
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
def _image_stale_checks(self, plan: SmolmachinesBottlePlan) -> None:
|
||||
_launch.stale_checks(plan)
|
||||
|
||||
@contextmanager
|
||||
def _launch_impl(
|
||||
def launch(
|
||||
self, plan: SmolmachinesBottlePlan
|
||||
) -> Generator[SmolmachinesBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
|
||||
@@ -47,24 +47,10 @@ _HOME_FOR = {
|
||||
"root": "/root",
|
||||
}
|
||||
|
||||
_DEFAULT_PATH_FOR = {
|
||||
# Committed smolmachine snapshots are rebuilt from a rootfs tarball and
|
||||
# lose Docker image ENV metadata. Restore the provider CLI path here so
|
||||
# resumed Codex bottles can still find the per-user install.
|
||||
"node": (
|
||||
"/home/node/.local/bin:"
|
||||
"/home/node/.codex/packages/standalone/current/bin:"
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
),
|
||||
"root": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
|
||||
|
||||
def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]:
|
||||
home = _HOME_FOR.get(user, f"/home/{user}")
|
||||
out = [f"HOME={home}", f"USER={user}"]
|
||||
if "PATH" not in env:
|
||||
out.append(f"PATH={_DEFAULT_PATH_FOR.get(user, _DEFAULT_PATH_FOR['root'])}")
|
||||
for k, v in env.items():
|
||||
out.append(f"{k}={v}")
|
||||
return out
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
"""Egress apply for the smolmachines backend.
|
||||
|
||||
The smolmachines sidecar bundle runs as a sidecar smolVM. Route-file
|
||||
inspection and reload signalling therefore go through ``smolvm machine
|
||||
exec`` instead of Docker.
|
||||
The smolmachines sidecar bundle runs as a host-side Docker container,
|
||||
so egress signalling is identical to the docker backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||
from ...log import warn
|
||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||
from . import sidecar_bundle as _bundle
|
||||
from . import smolvm as _smolvm
|
||||
|
||||
|
||||
def fetch_current_routes(slug: str) -> str:
|
||||
machine = _bundle.bundle_machine_name(slug)
|
||||
result = _smolvm.machine_exec(machine, ["cat", EGRESS_ROUTES_IN_CONTAINER])
|
||||
if result.returncode != 0:
|
||||
raise EgressApplyError(
|
||||
f"could not read routes.yaml from {machine}: "
|
||||
f"{(result.stderr or '').strip() or 'sidecar VM not running?'}"
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
class SmolmachinesEgressApplicator(EgressApplicator):
|
||||
def _signal_bundle_reload(self, slug: str) -> None:
|
||||
machine = _bundle.bundle_machine_name(slug)
|
||||
result = _smolvm.machine_exec(machine, ["sh", "-c", "kill -HUP 1"])
|
||||
if result.returncode != 0:
|
||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
warn(
|
||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
||||
f"{last_error or 'smolvm exec failed'}"
|
||||
)
|
||||
raise EgressApplyError(
|
||||
f"could not reload egress bundle {machine}: "
|
||||
f"{last_error or 'smolvm exec failed'}"
|
||||
)
|
||||
|
||||
|
||||
applicator = SmolmachinesEgressApplicator()
|
||||
|
||||
from ..docker.egress_apply import ( # noqa: F401
|
||||
DockerEgressApplicator,
|
||||
EgressApplyError,
|
||||
applicator,
|
||||
fetch_current_routes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SmolmachinesEgressApplicator",
|
||||
"DockerEgressApplicator",
|
||||
"EgressApplyError",
|
||||
"applicator",
|
||||
"fetch_current_routes",
|
||||
|
||||
@@ -31,7 +31,6 @@ from . import sidecar_bundle as _bundle
|
||||
# matching the bundle container name pattern. We use the prefix
|
||||
# both as a filter and to strip back to the slug.
|
||||
_VM_NAME_PREFIX = "bot-bottle-"
|
||||
_SIDECAR_VM_PREFIX = _bundle.bundle_machine_name("")
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
@@ -55,11 +54,7 @@ def enumerate_active() -> list[ActiveAgent]:
|
||||
for m in machines:
|
||||
name = m.get("name") or ""
|
||||
state = m.get("state") or ""
|
||||
if (
|
||||
state != "running"
|
||||
or not name.startswith(_VM_NAME_PREFIX)
|
||||
or name.startswith(_SIDECAR_VM_PREFIX)
|
||||
):
|
||||
if state != "running" or not name.startswith(_VM_NAME_PREFIX):
|
||||
continue
|
||||
slug = name[len(_VM_NAME_PREFIX):]
|
||||
metadata = read_metadata(slug)
|
||||
|
||||
@@ -27,7 +27,7 @@ from ...egress import (
|
||||
egress_resolve_token_values,
|
||||
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 ..docker import util as docker_mod
|
||||
from ..docker.egress import (
|
||||
@@ -45,15 +45,13 @@ from ...git_gate import (
|
||||
provision_git_gate_dynamic_keys,
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
from ...image_cache import check_stale_path
|
||||
from ...log import die, info, warn
|
||||
from ...log import info, warn
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from . import loopback_alias as _loopback
|
||||
from . import port_forward as _forward
|
||||
from . import sidecar_bundle as _bundle
|
||||
from . import smolvm as _smolvm
|
||||
from .bottle import SmolmachinesBottle
|
||||
@@ -93,12 +91,12 @@ def launch(
|
||||
try:
|
||||
loopback_ip, network = _allocate_resources(plan, stack)
|
||||
plan = _mint_certs(plan)
|
||||
proxy_host = loopback_ip
|
||||
plan = _start_bundle(plan, network, proxy_host, stack)
|
||||
plan = _start_bundle(plan, network, loopback_ip, stack)
|
||||
plan = _discover_urls(plan, loopback_ip)
|
||||
|
||||
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)
|
||||
|
||||
bottle = SmolmachinesBottle(
|
||||
@@ -144,17 +142,17 @@ def _allocate_resources(
|
||||
plan: SmolmachinesBottlePlan,
|
||||
stack: ExitStack,
|
||||
) -> tuple[str, str]:
|
||||
"""Reserve a per-bottle host address.
|
||||
"""Reserve a loopback alias and create the per-bottle docker bridge.
|
||||
|
||||
The per-bottle address scopes TSI's allowlist to this bottle's
|
||||
forwarder-published ports so the agent can't reach other bottles'
|
||||
or host services. The returned network name remains in the bundle
|
||||
spec for compatibility with older helper tests; no Docker sidecar
|
||||
container is launched."""
|
||||
del stack
|
||||
macOS only routes 127.0.0.1 by default; the per-bottle alias
|
||||
scopes TSI's allowlist to this bottle's published ports so the
|
||||
agent can't reach other bottles' or host services' ports on
|
||||
loopback. No-op on Linux."""
|
||||
_loopback.ensure_pool()
|
||||
loopback_ip = _loopback.allocate(plan.slug)
|
||||
network = _bundle.bundle_network_name(plan.slug)
|
||||
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
||||
stack.callback(_bundle.remove_bundle_network, network)
|
||||
return loopback_ip, network
|
||||
|
||||
|
||||
@@ -174,46 +172,18 @@ def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
|
||||
def _start_bundle(
|
||||
plan: SmolmachinesBottlePlan,
|
||||
network: str,
|
||||
proxy_host: str,
|
||||
loopback_ip: str,
|
||||
stack: ExitStack,
|
||||
) -> SmolmachinesBottlePlan:
|
||||
"""Build the BundleLaunchSpec, start the sidecar VM, wrap its raw
|
||||
smolVM-published loopback ports with per-bottle forwarders, stamp
|
||||
agent URLs from those forwarder ports, and register teardown."""
|
||||
"""Build the BundleLaunchSpec, resolve token env, start the
|
||||
sidecar bundle container, and register teardown."""
|
||||
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))
|
||||
if _image_policy(plan) == "cached":
|
||||
artifact = _cached_smolmachine(bundle_spec.image, label="sidecar")
|
||||
else:
|
||||
artifact = _ensure_smolmachine(
|
||||
bundle_spec.image,
|
||||
dockerfile=_bundle.SIDECAR_BUNDLE_DOCKERFILE,
|
||||
)
|
||||
launch = _bundle.start_bundle_vm(
|
||||
bundle_spec,
|
||||
from_path=artifact,
|
||||
host_env={**os.environ, **token_env},
|
||||
)
|
||||
stack.callback(_bundle.stop_bundle_vm, plan.slug)
|
||||
|
||||
forward_specs = tuple(
|
||||
_forward.ForwardSpec(
|
||||
label=_label_for_port(container_port),
|
||||
listen_host=proxy_host,
|
||||
listen_port=0,
|
||||
target_host="127.0.0.1",
|
||||
target_port=host_port,
|
||||
)
|
||||
for container_port, host_port in launch.raw_ports.items()
|
||||
)
|
||||
handle = _forward.start_forwarder(forward_specs)
|
||||
stack.callback(_forward.stop_forwarder, handle)
|
||||
published_ports = {
|
||||
_port_for_label(spec.label): spec.listen_port
|
||||
for spec in handle.forwards
|
||||
}
|
||||
return _discover_urls(plan, proxy_host, published_ports)
|
||||
_bundle.ensure_bundle_image(bundle_spec.image)
|
||||
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
||||
stack.callback(_bundle.stop_bundle, plan.slug)
|
||||
return plan
|
||||
|
||||
|
||||
def _provision_git_gate_keys(
|
||||
@@ -231,32 +201,39 @@ def _provision_git_gate_keys(
|
||||
|
||||
def _discover_urls(
|
||||
plan: SmolmachinesBottlePlan,
|
||||
proxy_host: str,
|
||||
published_ports: dict[int, int],
|
||||
loopback_ip: str,
|
||||
) -> SmolmachinesBottlePlan:
|
||||
"""Stamp URLs + guest_env from per-bottle forwarder ports.
|
||||
"""Discover host-side ports for published container ports and
|
||||
return the plan with URLs + guest_env stamped in.
|
||||
|
||||
`proxy_host` is the host IP that both TSI's allowlist and the
|
||||
forwarder listeners are keyed to. The raw smolVM-published ports
|
||||
are intentionally not advertised to the agent.
|
||||
Docker container IPs (192.168.x.x in the daemon's bridge)
|
||||
aren't reachable from the smolvm guest on macOS — TSI uses
|
||||
macOS networking, and macOS sees the daemon's bridge via the
|
||||
published-port loopback forward only.
|
||||
|
||||
NO_PROXY includes `proxy_host` so supervise + git-gate URLs
|
||||
bypass HTTPS_PROXY."""
|
||||
agent_facing_host_port = published_ports[_EGRESS_PORT]
|
||||
agent_proxy_url = f"http://{proxy_host}:{agent_facing_host_port}"
|
||||
NO_PROXY includes the per-bottle loopback alias so the
|
||||
supervise + git-gate URLs bypass HTTPS_PROXY."""
|
||||
agent_facing_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, _EGRESS_PORT, host_ip=loopback_ip,
|
||||
)
|
||||
agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
|
||||
|
||||
agent_git_gate_host = ""
|
||||
if plan.git_gate_plan.upstreams:
|
||||
git_gate_host_port = published_ports[_GIT_HTTP_PORT]
|
||||
agent_git_gate_host = f"{proxy_host}:{git_gate_host_port}"
|
||||
git_gate_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip,
|
||||
)
|
||||
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
|
||||
|
||||
agent_supervise_url = ""
|
||||
if plan.supervise_plan is not None:
|
||||
supervise_host_port = published_ports[_SUPERVISE_PORT]
|
||||
agent_supervise_url = f"http://{proxy_host}:{supervise_host_port}/"
|
||||
supervise_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
|
||||
)
|
||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
||||
|
||||
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 = {
|
||||
**plan.guest_env,
|
||||
"HTTPS_PROXY": agent_proxy_url,
|
||||
@@ -286,31 +263,27 @@ def _discover_urls(
|
||||
def _launch_vm(
|
||||
plan: SmolmachinesBottlePlan,
|
||||
agent_from_path: Path,
|
||||
proxy_host: str,
|
||||
loopback_ip: str,
|
||||
stack: ExitStack,
|
||||
) -> None:
|
||||
"""Create, patch, and start the smolvm VM; register teardown.
|
||||
|
||||
--allow-cidr is `proxy_host/32` — the per-bottle loopback alias.
|
||||
This ensures the guest can only reach sidecar forwarders published
|
||||
on that IP, not host localhost or another bottle's alias.
|
||||
force_allowlist confirms the allowlist persisted (patching smolvm
|
||||
0.8.0's silent-drop of --allow-cidr when combined with --from) and
|
||||
fails closed if it can't. Smolfile isn't usable here — smolvm 0.8.0
|
||||
makes --from and --smolfile mutually exclusive."""
|
||||
tsi_cidr = f"{proxy_host}/32"
|
||||
--allow-cidr is the per-bottle loopback alias so the guest can
|
||||
only reach this bottle's bundle ports. force_allowlist patches
|
||||
smolvm 0.8.0's silent-drop of --allow-cidr when combined with
|
||||
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
|
||||
and --smolfile mutually exclusive."""
|
||||
_smolvm.machine_create(
|
||||
plan.machine_name,
|
||||
from_path=agent_from_path,
|
||||
allow_cidrs=[tsi_cidr],
|
||||
allow_cidrs=[f"{loopback_ip}/32"],
|
||||
env=plan.guest_env,
|
||||
)
|
||||
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
||||
# Confirm the booted VM's TSI allowlist will actually enforce the
|
||||
# /32 before start (smolvm 0.8.0 silently drops `--allow-cidr`
|
||||
# with `--from`, so the persisted state DB is patched if needed).
|
||||
# Fails closed if enforcement can't be confirmed.
|
||||
_loopback.force_allowlist(plan.machine_name, [tsi_cidr])
|
||||
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
|
||||
# when combined with `--from`. Patch the persisted state DB
|
||||
# before start so the booted VM's TSI actually enforces.
|
||||
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
|
||||
_smolvm.machine_start(plan.machine_name)
|
||||
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
||||
|
||||
@@ -319,9 +292,7 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
||||
"""Repair filesystem ownership and wait for exec channel readiness.
|
||||
|
||||
Ownership repair: smolvm's pack process remaps files to the host
|
||||
invoker's uid (e.g. 501 on macOS, 1000 on Linux). The chowns use
|
||||
names not numbers so they're correct on either. /home/node must
|
||||
be node:node so
|
||||
invoker's uid (501 on macOS). /home/node must be node:node so
|
||||
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
|
||||
mode 1777 so non-root processes can create per-uid scratch dirs.
|
||||
All folded into one sh -c to avoid back-to-back exec calls
|
||||
@@ -344,30 +315,8 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
||||
_smolvm.wait_exec_ready(plan.machine_name)
|
||||
|
||||
|
||||
def _label_for_port(port: int) -> str:
|
||||
if port == _EGRESS_PORT:
|
||||
return "egress"
|
||||
if port == _GIT_HTTP_PORT:
|
||||
return "git-http"
|
||||
if port == _SUPERVISE_PORT:
|
||||
return "supervise"
|
||||
return f"port-{port}"
|
||||
|
||||
|
||||
def _port_for_label(label: str) -> int:
|
||||
if label == "egress":
|
||||
return _EGRESS_PORT
|
||||
if label == "git-http":
|
||||
return _GIT_HTTP_PORT
|
||||
if label == "supervise":
|
||||
return _SUPERVISE_PORT
|
||||
if label.startswith("port-"):
|
||||
return int(label.removeprefix("port-"))
|
||||
raise ValueError(f"unknown sidecar forward label: {label}")
|
||||
|
||||
|
||||
def _bundle_launch_spec(
|
||||
plan: SmolmachinesBottlePlan, network: str, proxy_host: str,
|
||||
plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
|
||||
) -> _bundle.BundleLaunchSpec:
|
||||
"""Build a BundleLaunchSpec from the resolved inner Plans.
|
||||
|
||||
@@ -420,15 +369,14 @@ def _bundle_launch_spec(
|
||||
daemons.append("supervise")
|
||||
env += [
|
||||
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}",
|
||||
]
|
||||
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 —
|
||||
# published on `proxy_host` so the TSI allowlist and the docker
|
||||
# port-forward bindings point at the same IP. Egress is always
|
||||
# the agent's HTTP/HTTPS proxy.
|
||||
# published on host loopback so the guest can dial via TSI +
|
||||
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
|
||||
ports_to_publish: list[int] = [_EGRESS_PORT]
|
||||
if gp.upstreams:
|
||||
ports_to_publish.append(_GIT_HTTP_PORT)
|
||||
@@ -445,7 +393,7 @@ def _bundle_launch_spec(
|
||||
environment=tuple(env),
|
||||
volumes=tuple(volumes),
|
||||
ports_to_publish=tuple(ports_to_publish),
|
||||
publish_host_ip=proxy_host,
|
||||
publish_host_ip=loopback_ip,
|
||||
)
|
||||
|
||||
|
||||
@@ -473,9 +421,6 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
||||
return committed_path
|
||||
|
||||
if _image_policy(plan) == "cached":
|
||||
return _cached_smolmachine(plan.agent_image, label="agent")
|
||||
|
||||
# Build the agent image and pack it into a `.smolmachine`
|
||||
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
||||
# not in prepare, so the docker-build output doesn't garble the
|
||||
@@ -486,60 +431,6 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||
)
|
||||
|
||||
|
||||
def stale_checks(plan: SmolmachinesBottlePlan) -> None:
|
||||
"""Raise StaleImageError if a cached smolmachine artifact is older than the
|
||||
configured threshold. Only runs when image_policy is 'cached'. Checks the
|
||||
committed agent artifact first, then the cache-keyed agent and sidecar
|
||||
artifacts. Called by the backend class's _image_stale_checks before any
|
||||
resources are allocated."""
|
||||
if _image_policy(plan) != "cached":
|
||||
return
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed:
|
||||
committed_path = Path(committed)
|
||||
if committed_path.is_file():
|
||||
check_stale_path("agent smolmachine artifact", committed_path)
|
||||
elif docker_mod.image_exists(plan.agent_image):
|
||||
digest = docker_mod.image_id(plan.agent_image).split(":", 1)[-1][:16]
|
||||
artifact = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
||||
if artifact.is_file():
|
||||
check_stale_path("agent smolmachine artifact", artifact)
|
||||
sidecar_image = _bundle.SIDECAR_BUNDLE_IMAGE
|
||||
if docker_mod.image_exists(sidecar_image):
|
||||
digest = docker_mod.image_id(sidecar_image).split(":", 1)[-1][:16]
|
||||
sidecar_artifact = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
||||
if sidecar_artifact.is_file():
|
||||
check_stale_path("sidecar smolmachine artifact", sidecar_artifact)
|
||||
|
||||
|
||||
def _image_policy(plan: object) -> str:
|
||||
spec = getattr(plan, "spec", None)
|
||||
return str(getattr(spec, "image_policy", "fresh"))
|
||||
|
||||
|
||||
def _cached_smolmachine(image_ref: str, *, label: str) -> Path:
|
||||
"""Return the cached smolmachine artifact for the current local image.
|
||||
|
||||
This is intentionally buildless: it inspects the existing local Docker
|
||||
image ID and looks for the artifact keyed by that ID. If either side is
|
||||
missing, the caller must use the fresh path to build/pack it.
|
||||
"""
|
||||
if not docker_mod.image_exists(image_ref):
|
||||
die(
|
||||
f"cached {label} image {image_ref!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
||||
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
||||
if not sidecar.is_file():
|
||||
die(
|
||||
f"cached {label} smolmachine artifact for {image_ref!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
info(f"using cached {label} smolmachine {str(sidecar)!r}")
|
||||
return sidecar
|
||||
|
||||
|
||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||
"""Build the agent docker image and convert it into a
|
||||
`.smolmachine` artifact, caching the result under
|
||||
@@ -576,12 +467,6 @@ def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||
return sidecar
|
||||
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
|
||||
docker_mod.save(image_ref, str(tarball))
|
||||
# On Linux, `docker save -o` writes the tarball with owner-only
|
||||
# permissions (mode 600). The crane push container runs as UID
|
||||
# 65532 (distroless nonroot) and can't read it through a bind
|
||||
# mount unless world-read is set. The tarball is temporary and
|
||||
# lives in ~/.cache, so 644 is safe.
|
||||
tarball.chmod(0o644)
|
||||
try:
|
||||
with ephemeral_registry() as handle:
|
||||
push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
|
||||
|
||||
@@ -33,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
|
||||
prompt.
|
||||
|
||||
On Linux the whole `127.0.0.0/8` is already routed to `lo`, so
|
||||
docker can publish a bundle's ports directly on `127.0.0.<N>`
|
||||
with no `ifconfig`/sudo step. `ensure_pool` is therefore a no-op
|
||||
on Linux, but per-bottle alias *allocation* and the TSI allowlist
|
||||
DB patch run on both platforms — the isolation property is
|
||||
identical, it's just cheaper to set up on Linux. The state-DB
|
||||
path differs per platform (see `_smolvm_db_path`).
|
||||
Linux native daemons share the host's network namespace; the
|
||||
whole `127.0.0.0/8` is reachable by default and aliases are
|
||||
unnecessary. The pool logic detects native-Linux and skips sudo
|
||||
entirely; the DB patch is also gated on macOS.
|
||||
|
||||
Allocation is coordinated by inspecting running bundle
|
||||
containers' published host IPs — each bottle's bundle owns the
|
||||
@@ -50,7 +47,6 @@ from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -61,34 +57,20 @@ from typing import Iterable
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
def _smolvm_db_path() -> Path:
|
||||
"""smolvm's persistent VM state — a SQLite DB whose `vms` table
|
||||
holds one JSON BLOB per machine. macOS stores it under
|
||||
`Application Support`; Linux follows the XDG base-dir spec
|
||||
(`$XDG_DATA_HOME`, default `~/.local/share`).
|
||||
|
||||
NOTE: the Linux location is inferred from smolvm's documented
|
||||
`~/.local/share` install layout and must be confirmed against a
|
||||
real Linux smolvm install. If it's wrong, `force_allowlist`'s
|
||||
fail-closed check turns it into a clear launch-time error rather
|
||||
than a silent escape."""
|
||||
if platform.system() == "Darwin":
|
||||
return (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "smolvm"
|
||||
/ "server"
|
||||
/ "smolvm.db"
|
||||
)
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME")
|
||||
base = Path(xdg_data) if xdg_data else Path.home() / ".local" / "share"
|
||||
return base / "smolvm" / "server" / "smolvm.db"
|
||||
|
||||
|
||||
# Resolved once at import: the host platform doesn't change within a
|
||||
# process. Tests patch this attribute directly.
|
||||
_SMOLVM_DB_PATH = _smolvm_db_path()
|
||||
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
|
||||
# table holds one JSON BLOB per machine. The Linux path is
|
||||
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
|
||||
# we hard-code this. If the file moves under us we'll see a
|
||||
# clear FileNotFoundError; not worth defensive cross-platform
|
||||
# detection until the backend actually needs Linux.
|
||||
_SMOLVM_DB_PATH = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "smolvm"
|
||||
/ "server"
|
||||
/ "smolvm.db"
|
||||
)
|
||||
|
||||
|
||||
# Sixteen aliases by default. Tunable for hosts that want more
|
||||
@@ -149,74 +131,51 @@ def ensure_pool() -> None:
|
||||
|
||||
|
||||
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
||||
"""Ensure the machine's persisted TSI allowlist equals
|
||||
`allowed_cidrs`, failing **closed** if that can't be confirmed.
|
||||
"""Patch smolvm's persistent VM-state DB to set the machine's
|
||||
`allowed_cidrs` to the given list. Workaround for smolvm
|
||||
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
|
||||
|
||||
Runs on both macOS and Linux. It exists because smolvm 0.8.0
|
||||
silently drops `--allow-cidr` when combined with `--from`, so
|
||||
the allowlist has to be written into smolvm's persistent state
|
||||
DB before `machine start`. Rather than assume the flag was
|
||||
dropped, we read the persisted row and only patch when it
|
||||
doesn't already match — so a newer smolvm that honors the flag
|
||||
is left untouched.
|
||||
Must run AFTER `smolvm machine create` (the row has to
|
||||
exist) and BEFORE `smolvm machine start` (smolvm reads the
|
||||
row on start; in-flight VMs don't pick up changes). Once
|
||||
smolvm honors the CLI flag upstream this whole function is
|
||||
redundant — flag-respecting create + remove this call from
|
||||
launch.
|
||||
|
||||
Must run AFTER `smolvm machine create` (the row has to exist)
|
||||
and BEFORE `smolvm machine start` (smolvm reads the row on
|
||||
start; in-flight VMs don't pick up changes).
|
||||
|
||||
Fail-closed: if the state DB is missing, the row is missing, or
|
||||
the allowlist still doesn't match after patching, we `die()`
|
||||
rather than boot a VM whose egress confinement we can't verify
|
||||
— an unconfirmed allowlist is a sandbox-escape risk (the agent
|
||||
VM could reach all of host loopback)."""
|
||||
want = list(allowed_cidrs)
|
||||
No-op on non-macOS — the DB path differs and the Linux
|
||||
smolmachines code path isn't exercised in v1."""
|
||||
if not _is_macos():
|
||||
return
|
||||
if not _SMOLVM_DB_PATH.is_file():
|
||||
die(
|
||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}; cannot "
|
||||
f"confirm the TSI allowlist is enforced. Refusing to launch "
|
||||
f"(fail-closed). Check `smolvm --version` and the DB "
|
||||
f"location for your platform."
|
||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
|
||||
f"smolvm 0.8.0 expected? `smolvm --version` to check."
|
||||
)
|
||||
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
|
||||
try:
|
||||
cfg = _read_machine_cfg(con, machine_name)
|
||||
if cfg.get("allowed_cidrs") != want:
|
||||
cfg["allowed_cidrs"] = want
|
||||
# Write as BLOB (the column type smolvm uses) — passing a
|
||||
# plain str makes sqlite store it as Text and smolvm then
|
||||
# fails to read it.
|
||||
con.execute(
|
||||
"UPDATE vms SET data = ? WHERE name = ?",
|
||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
||||
)
|
||||
con.commit()
|
||||
cfg = _read_machine_cfg(con, machine_name)
|
||||
if cfg.get("allowed_cidrs") != want:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
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)."
|
||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
||||
f"machine_create must run before force_allowlist."
|
||||
)
|
||||
cfg = json.loads(row[0])
|
||||
cfg["allowed_cidrs"] = list(allowed_cidrs)
|
||||
# Write as BLOB (the column type smolvm uses) — passing a
|
||||
# plain str makes sqlite store it as Text and smolvm then
|
||||
# fails to read it.
|
||||
cur.execute(
|
||||
"UPDATE vms SET data = ? WHERE name = ?",
|
||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
||||
)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def _read_machine_cfg(con: sqlite3.Connection, machine_name: str) -> dict[str, object]:
|
||||
"""Read + JSON-decode a machine's `data` BLOB from the smolvm
|
||||
state DB. Dies (fail-closed) if the row is missing — the caller
|
||||
can't confirm enforcement without it."""
|
||||
row = con.execute(
|
||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
die(
|
||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
||||
f"machine_create must run before force_allowlist."
|
||||
)
|
||||
return json.loads(row[0])
|
||||
|
||||
|
||||
def allocate(_slug: str) -> str:
|
||||
"""Pick the lowest-numbered alias from the pool not already
|
||||
in use by a running smolmachines bundle. Bails when the pool
|
||||
@@ -225,17 +184,16 @@ def allocate(_slug: str) -> str:
|
||||
used (no on-disk reservation, allocation is purely
|
||||
docker-state-driven).
|
||||
|
||||
Runs on both platforms: the allocation logic (docker-state
|
||||
inspection + the file lock) is platform-independent. macOS
|
||||
needs `ensure_pool` to have aliased the addresses on `lo0`
|
||||
first; on Linux all of `127.0.0.0/8` is already loopback, so
|
||||
docker can publish on the chosen `127.0.0.<N>` with no setup.
|
||||
Per-bottle scoping (so the agent can't reach other bottles' or
|
||||
host services' loopback ports) therefore holds on both.
|
||||
On non-macOS the whole `127.0.0.0/8` is loopback by default;
|
||||
`127.0.0.1` is fine to share and we skip the alias dance.
|
||||
This still returns a deterministic address so launch.py's
|
||||
callers don't have to branch on platform.
|
||||
|
||||
An exclusive file lock serialises concurrent calls so two
|
||||
simultaneous launches don't read the same docker state and
|
||||
claim the same alias."""
|
||||
if not _is_macos():
|
||||
return "127.0.0.1"
|
||||
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"""Per-bottle TCP forwarder for smolmachines sidecar VMs.
|
||||
|
||||
smolVM currently publishes guest ports on host loopback as
|
||||
``HOST:GUEST`` port pairs, without an address-scoped bind. The agent
|
||||
VM's TSI allowlist is IP-only, so bot-bottle exposes a second, stricter
|
||||
surface: listeners bound only to the bottle's allocated host address,
|
||||
forwarding to the raw smolVM-published loopback ports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import selectors
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from ...log import die, warn
|
||||
|
||||
|
||||
_BUFFER_SIZE = 64 * 1024
|
||||
_LOOPBACK_TARGETS = {"127.0.0.1", "::1"}
|
||||
_FORBIDDEN_LISTEN_HOSTS = {"", "0.0.0.0", "::", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForwardSpec:
|
||||
label: str
|
||||
listen_host: str
|
||||
listen_port: int
|
||||
target_host: str
|
||||
target_port: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForwarderHandle:
|
||||
process: Any
|
||||
forwards: tuple[ForwardSpec, ...]
|
||||
|
||||
|
||||
def start_forwarder(specs: Sequence[ForwardSpec]) -> ForwarderHandle:
|
||||
"""Start one helper process for a bottle and wait until all
|
||||
listeners are bound. ``listen_port`` may be 0; the returned specs
|
||||
contain the kernel-assigned ports printed by the helper."""
|
||||
if not specs:
|
||||
return ForwarderHandle(process=_CompletedProcess(), forwards=())
|
||||
_validate_specs(specs)
|
||||
argv = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from bot_bottle.backend.smolmachines.port_forward import main; "
|
||||
"raise SystemExit(main())",
|
||||
"--spec-json",
|
||||
json.dumps([asdict(s) for s in specs], separators=(",", ":")),
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None,
|
||||
text=True,
|
||||
env=os.environ,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
line = proc.stdout.readline()
|
||||
proc.stdout.close()
|
||||
if not line:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
die("smolmachines forwarder failed before reporting readiness")
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
bound = tuple(ForwardSpec(**item) for item in payload["forwards"])
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
die(f"smolmachines forwarder returned invalid readiness payload: {exc}")
|
||||
return ForwarderHandle(process=proc, forwards=bound)
|
||||
|
||||
|
||||
def stop_forwarder(handle: ForwarderHandle) -> None:
|
||||
proc = handle.process
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def _validate_specs(specs: Iterable[ForwardSpec]) -> None:
|
||||
for spec in specs:
|
||||
if spec.listen_host in _FORBIDDEN_LISTEN_HOSTS:
|
||||
die(f"refusing unsafe smolmachines forwarder bind: {spec.listen_host!r}")
|
||||
if spec.target_host not in _LOOPBACK_TARGETS:
|
||||
die(f"refusing non-loopback smolmachines forwarder target: {spec.target_host!r}")
|
||||
if not 0 <= spec.listen_port <= 65535:
|
||||
die(f"invalid smolmachines forwarder listen port: {spec.listen_port}")
|
||||
if not 1 <= spec.target_port <= 65535:
|
||||
die(f"invalid smolmachines forwarder target port: {spec.target_port}")
|
||||
|
||||
|
||||
class _CompletedProcess:
|
||||
"""Popen-like no-op used when a bottle has no forwards."""
|
||||
|
||||
def poll(self) -> int:
|
||||
return 0
|
||||
|
||||
def terminate(self) -> None:
|
||||
return None
|
||||
|
||||
def kill(self) -> None:
|
||||
return None
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
del timeout
|
||||
return 0
|
||||
|
||||
|
||||
class _Forwarder:
|
||||
def __init__(self, specs: Sequence[ForwardSpec]):
|
||||
_validate_specs(specs)
|
||||
self.specs = tuple(specs)
|
||||
self.selector = selectors.DefaultSelector()
|
||||
self.stop_event = threading.Event()
|
||||
self.listeners: list[tuple[socket.socket, ForwardSpec]] = []
|
||||
self.bound: list[ForwardSpec] = []
|
||||
|
||||
def start(self) -> None:
|
||||
for spec in self.specs:
|
||||
listener = socket.create_server(
|
||||
(spec.listen_host, spec.listen_port),
|
||||
reuse_port=False,
|
||||
backlog=64,
|
||||
)
|
||||
listener.setblocking(False)
|
||||
host, port = listener.getsockname()[:2]
|
||||
bound = ForwardSpec(
|
||||
label=spec.label,
|
||||
listen_host=str(host),
|
||||
listen_port=int(port),
|
||||
target_host=spec.target_host,
|
||||
target_port=spec.target_port,
|
||||
)
|
||||
self.listeners.append((listener, bound))
|
||||
self.bound.append(bound)
|
||||
self.selector.register(listener, selectors.EVENT_READ, bound)
|
||||
|
||||
def serve(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
events = self.selector.select(timeout=0.25)
|
||||
except OSError:
|
||||
return
|
||||
for key, _ in events:
|
||||
listener = key.fileobj
|
||||
if not isinstance(listener, socket.socket):
|
||||
continue
|
||||
spec = key.data
|
||||
try:
|
||||
client, _ = listener.accept()
|
||||
except OSError:
|
||||
continue
|
||||
threading.Thread(
|
||||
target=self._handle_client,
|
||||
args=(client, spec),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
for listener, _ in self.listeners:
|
||||
try:
|
||||
self.selector.unregister(listener)
|
||||
except Exception: # noqa: BLE001 - best effort shutdown
|
||||
pass
|
||||
listener.close()
|
||||
self.selector.close()
|
||||
|
||||
def _handle_client(self, client: socket.socket, spec: ForwardSpec) -> None:
|
||||
with client:
|
||||
try:
|
||||
target = socket.create_connection((spec.target_host, spec.target_port))
|
||||
except OSError as exc:
|
||||
warn(f"smolmachines forwarder {spec.label}: target connect failed: {exc}")
|
||||
return
|
||||
with target:
|
||||
left = threading.Thread(target=_pipe, args=(client, target), daemon=True)
|
||||
right = threading.Thread(target=_pipe, args=(target, client), daemon=True)
|
||||
left.start()
|
||||
right.start()
|
||||
left.join()
|
||||
right.join()
|
||||
|
||||
|
||||
def _pipe(src: socket.socket, dst: socket.socket) -> None:
|
||||
while True:
|
||||
try:
|
||||
data = src.recv(_BUFFER_SIZE)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
dst.sendall(data)
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
dst.shutdown(socket.SHUT_WR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--spec-json", required=True)
|
||||
ns = parser.parse_args(argv)
|
||||
specs = tuple(ForwardSpec(**item) for item in json.loads(ns.spec_json))
|
||||
forwarder = _Forwarder(specs)
|
||||
|
||||
def request_stop(signum: int, _frame: object) -> None:
|
||||
del signum
|
||||
forwarder.stop()
|
||||
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
forwarder.start()
|
||||
print(json.dumps({"forwards": [asdict(s) for s in forwarder.bound]}), flush=True)
|
||||
try:
|
||||
forwarder.serve()
|
||||
finally:
|
||||
forwarder.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -23,8 +23,6 @@ Plans (EgressPlan, …) lands in chunk 2d."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -36,7 +34,6 @@ from ..docker.sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
)
|
||||
from . import smolvm as _smolvm
|
||||
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
@@ -57,11 +54,6 @@ def bundle_container_name(slug: str) -> str:
|
||||
return f"bot-bottle-sidecars-{slug}"
|
||||
|
||||
|
||||
def bundle_machine_name(slug: str) -> str:
|
||||
"""Sidecar smolVM machine name."""
|
||||
return bundle_container_name(slug)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleLaunchSpec:
|
||||
"""Everything `start_bundle` needs to bring up one bundle
|
||||
@@ -100,11 +92,6 @@ class BundleLaunchSpec:
|
||||
publish_host_ip: str = "127.0.0.1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleVmLaunch:
|
||||
raw_ports: dict[int, int]
|
||||
|
||||
|
||||
def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
||||
"""Build the sidecar bundle image before `docker run`.
|
||||
|
||||
@@ -120,70 +107,6 @@ def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
||||
)
|
||||
|
||||
|
||||
def allocate_raw_host_ports(container_ports: Sequence[int]) -> dict[int, int]:
|
||||
"""Reserve candidate host loopback ports for smolVM `-p HOST:GUEST`.
|
||||
|
||||
The sockets are closed before smolVM binds them, so this remains
|
||||
best-effort. smolVM failing to bind a selected port is fatal in
|
||||
the caller's launch path."""
|
||||
out: dict[int, int] = {}
|
||||
for container_port in container_ports:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
out[container_port] = int(sock.getsockname()[1])
|
||||
return out
|
||||
|
||||
|
||||
def start_bundle_vm(
|
||||
spec: BundleLaunchSpec,
|
||||
*,
|
||||
from_path: Path,
|
||||
host_env: dict[str, str] | None = None,
|
||||
) -> BundleVmLaunch:
|
||||
"""Create and start the sidecar bundle as a smolVM.
|
||||
|
||||
smolVM's own published ports are raw host-loopback ports. The
|
||||
launch flow wraps them with per-bottle address-bound forwarders
|
||||
before exposing anything to the agent VM."""
|
||||
raw_ports = allocate_raw_host_ports(spec.ports_to_publish)
|
||||
effective_host_env = host_env if host_env is not None else os.environ
|
||||
env: dict[str, str] = {"BOT_BOTTLE_SIDECAR_DAEMONS": spec.daemons_csv}
|
||||
for entry in spec.environment:
|
||||
name, sep, value = entry.partition("=")
|
||||
if sep:
|
||||
env[name] = value
|
||||
elif entry in effective_host_env:
|
||||
env[entry] = effective_host_env[entry]
|
||||
name = bundle_machine_name(spec.slug)
|
||||
_smolvm.machine_create(
|
||||
name,
|
||||
from_path=from_path,
|
||||
net=True,
|
||||
env=env,
|
||||
volumes=spec.volumes,
|
||||
ports=tuple(
|
||||
(host_port, guest_port)
|
||||
for guest_port, host_port in raw_ports.items()
|
||||
),
|
||||
)
|
||||
_smolvm.machine_start(name)
|
||||
_smolvm.wait_exec_ready(name)
|
||||
return BundleVmLaunch(raw_ports=raw_ports)
|
||||
|
||||
|
||||
def stop_bundle_vm(slug: str) -> None:
|
||||
"""Best-effort sidecar VM teardown."""
|
||||
name = bundle_machine_name(slug)
|
||||
try:
|
||||
_smolvm.machine_stop(name)
|
||||
except _smolvm.SmolvmError as exc:
|
||||
warn(f"smolvm machine stop {name} failed: {exc}")
|
||||
try:
|
||||
_smolvm.machine_delete(name)
|
||||
except _smolvm.SmolvmError as exc:
|
||||
warn(f"smolvm machine delete {name} failed: {exc}")
|
||||
|
||||
|
||||
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
||||
"""`docker network create` with an explicit subnet + gateway
|
||||
so the bundle's `--ip` lands on the address the Smolfile's
|
||||
|
||||
@@ -113,15 +113,13 @@ def machine_create(
|
||||
*,
|
||||
image: str | None = None,
|
||||
from_path: Path | None = None,
|
||||
net: bool = False,
|
||||
allow_cidrs: Sequence[str] = (),
|
||||
env: Mapping[str, str] | None = None,
|
||||
ports: Sequence[tuple[int, int]] = (),
|
||||
volumes: Sequence[tuple[str, str, bool]] = (),
|
||||
) -> None:
|
||||
"""`smolvm machine create --name NAME [--image IMG | --from PATH]
|
||||
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is passed as
|
||||
`--name` (smolvm 1.4.7+; earlier versions took it positionally).
|
||||
"""`smolvm machine create NAME [--image IMG | --from PATH]
|
||||
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is positional
|
||||
(the CLI's exception to the `--name` pattern other
|
||||
subcommands use).
|
||||
|
||||
`image` (registry ref like `alpine:latest`) and `from_path`
|
||||
(a `.smolmachine` artifact) are mutually exclusive — one or
|
||||
@@ -134,27 +132,25 @@ def machine_create(
|
||||
no-pull-at-start property. The flag form gives the same
|
||||
result without the Smolfile complication.
|
||||
|
||||
`--net` is sent explicitly when `net=True` or `allow_cidrs` is
|
||||
non-empty. `--allow-cidr` implies `--net` per the CLI help, but
|
||||
sending `--net` explicitly is harmless and ensures the guest has
|
||||
network access even if that implication changes across versions."""
|
||||
args: list[str] = ["machine", "create", "--name", name]
|
||||
`--net` is sent explicitly when `allow_cidrs` is non-empty.
|
||||
smolvm 0.8.0's docs say `--allow-cidr` implies `--net`, but
|
||||
empirically the implication only fires when no `--from` is
|
||||
set — `--from PATH --allow-cidr X/32` silently produces a
|
||||
machine with `network: false` and no routes in the guest, so
|
||||
the agent can't reach the bundle's pinned IP."""
|
||||
args: list[str] = ["machine", "create"]
|
||||
if image is not None:
|
||||
args += ["--image", image]
|
||||
if from_path is not None:
|
||||
args += ["--from", str(from_path)]
|
||||
if net or allow_cidrs:
|
||||
if allow_cidrs:
|
||||
args.append("--net")
|
||||
for cidr in allow_cidrs:
|
||||
args += ["--allow-cidr", cidr]
|
||||
for host_port, guest_port in ports:
|
||||
args += ["-p", f"{host_port}:{guest_port}"]
|
||||
for host_path, guest_path, read_only in volumes:
|
||||
suffix = ":ro" if read_only else ""
|
||||
args += ["-v", f"{host_path}:{guest_path}{suffix}"]
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
args += ["-e", f"{k}={v}"]
|
||||
args.append(name)
|
||||
_smolvm(*args)
|
||||
|
||||
|
||||
@@ -186,9 +182,10 @@ def machine_stop(name: str) -> None:
|
||||
|
||||
|
||||
def machine_delete(name: str) -> None:
|
||||
"""`smolvm machine delete --name NAME -f`. `-f` skips the
|
||||
interactive confirmation — required for non-interactive teardown."""
|
||||
_smolvm("machine", "delete", "--name", name, "-f")
|
||||
"""`smolvm machine delete -f NAME`. NAME is positional. `-f`
|
||||
skips the interactive confirmation — required for
|
||||
non-interactive teardown."""
|
||||
_smolvm("machine", "delete", "-f", name)
|
||||
|
||||
|
||||
def machine_exec(
|
||||
|
||||
@@ -5,58 +5,26 @@ unit-tested without importing the docker subprocess paths."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
from ...log import die
|
||||
|
||||
# libkrun's Linux backend drives the guest through KVM, so the host
|
||||
# must expose `/dev/kvm` and the invoking user must be able to open
|
||||
# it. macOS uses Hypervisor.framework and needs no device node.
|
||||
_KVM_DEVICE = "/dev/kvm"
|
||||
|
||||
|
||||
def smolmachines_preflight() -> None:
|
||||
"""Ensure the host can run the smolmachines backend before the
|
||||
launch flow starts. Called from `_resolve_plan`; surfaces a
|
||||
clear, actionable error instead of a cryptic `smolvm` failure
|
||||
deep in launch.
|
||||
|
||||
Checks `smolvm` is on PATH (both platforms) and, on Linux,
|
||||
that `/dev/kvm` exists and is accessible. `gvproxy` is no
|
||||
longer required — see the PRD's design pivot section."""
|
||||
if shutil.which("smolvm") is None:
|
||||
die(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
||||
"To use the legacy Docker backend instead, set "
|
||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
||||
)
|
||||
if platform.system() == "Linux":
|
||||
_preflight_kvm()
|
||||
|
||||
|
||||
def _preflight_kvm() -> None:
|
||||
"""Linux-only: libkrun needs `/dev/kvm`. Distinguish 'KVM not
|
||||
enabled' from 'no permission' so the operator knows which to
|
||||
fix."""
|
||||
if not os.path.exists(_KVM_DEVICE):
|
||||
die(
|
||||
f"BOT_BOTTLE_BACKEND=smolmachines needs {_KVM_DEVICE} on "
|
||||
"Linux but it is missing. Enable KVM: load the kvm-intel "
|
||||
"or kvm-amd kernel module (and confirm virtualization is "
|
||||
"enabled in BIOS/firmware). To use the legacy Docker "
|
||||
"backend instead, set BOT_BOTTLE_BACKEND=docker."
|
||||
)
|
||||
if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK):
|
||||
die(
|
||||
f"{_KVM_DEVICE} exists but is not readable/writable by the "
|
||||
"current user. Add your user to the `kvm` group "
|
||||
"(`sudo usermod -aG kvm \"$USER\"`) and re-login, or run "
|
||||
"with access to the device."
|
||||
)
|
||||
"""Ensure `smolvm` is on PATH before the launch flow runs.
|
||||
Called from `_resolve_plan`; gives the operator a clear
|
||||
install pointer rather than a cryptic FileNotFoundError
|
||||
later. `gvproxy` is no longer required — see the PRD's design
|
||||
pivot section."""
|
||||
if shutil.which("smolvm") is not None:
|
||||
return
|
||||
die(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
||||
"To use the legacy Docker backend instead, set "
|
||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
||||
)
|
||||
|
||||
|
||||
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
||||
|
||||
@@ -284,8 +284,9 @@ def git_gate_state_dir(identity: str) -> Path:
|
||||
|
||||
def supervise_state_dir(identity: str) -> Path:
|
||||
"""State subdir reserved for supervise sidecar bind-mount sources.
|
||||
Runtime queue/audit rows live in the host-level bot-bottle SQLite
|
||||
database, so they survive state-dir cleanup."""
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,8 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ..errors import MissingEnvVarError
|
||||
from ..log import Die, die, error
|
||||
from ..manifest import ManifestError
|
||||
from ..store_manager import StoreManager
|
||||
from ._common import PROG
|
||||
from . import list as _list_mod
|
||||
from .cleanup import cmd_cleanup
|
||||
@@ -76,25 +74,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if handler is None:
|
||||
usage()
|
||||
die(f"unknown command: {command}")
|
||||
mgr = StoreManager.instance()
|
||||
if not mgr.is_migrated():
|
||||
sys.stderr.write("bot-bottle: database schema is out of date\n")
|
||||
sys.stderr.write("Migrate now? [y/N] ")
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
answer = sys.stdin.readline().strip().lower()
|
||||
except EOFError:
|
||||
answer = ""
|
||||
if answer != "y":
|
||||
error("migration required — re-run and confirm to migrate")
|
||||
return 1
|
||||
mgr.migrate()
|
||||
try:
|
||||
return handler(rest) or 0
|
||||
except MissingEnvVarError as e:
|
||||
error(str(e))
|
||||
return 1
|
||||
except ManifestError as e:
|
||||
# Manifest/config problems surface as a catchable exception;
|
||||
# print the reason and exit non-zero (same UX die() used to give).
|
||||
error(str(e))
|
||||
return 1
|
||||
except Die as e:
|
||||
|
||||
+26
-67
@@ -36,7 +36,6 @@ from ..bottle_state import (
|
||||
is_preserved,
|
||||
mark_preserved,
|
||||
)
|
||||
from ..image_cache import StaleImageError
|
||||
from ..log import info, die
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ._common import PROG, USER_CWD, read_tty_line
|
||||
@@ -64,14 +63,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
"skip all prompts. For orchestrators, CI, and webhooks."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cached-images",
|
||||
action="store_true",
|
||||
help=(
|
||||
"quickstart with existing local agent and sidecar images; "
|
||||
"only valid with --headless"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bottle",
|
||||
action="append",
|
||||
@@ -104,8 +95,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
help="agent name defined in bot-bottle.json (omit to pick interactively)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.cached_images and not args.headless:
|
||||
die("--cached-images is only supported with --headless")
|
||||
|
||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||
|
||||
@@ -119,13 +108,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
|
||||
agent_name: str | None = args.name
|
||||
if agent_name is None:
|
||||
if not manifest.all_agent_names:
|
||||
print(
|
||||
"bot-bottle: no agents defined. "
|
||||
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
agent_name = tui.filter_select(
|
||||
manifest.all_agent_names,
|
||||
title="Select agent",
|
||||
@@ -153,10 +135,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
label, color = tui.name_color_modal(default_label=agent_name)
|
||||
label, color = _resolve_unique_label(label, color)
|
||||
|
||||
image_policy = _select_image_policy()
|
||||
if image_policy is None:
|
||||
return 0
|
||||
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name=agent_name,
|
||||
@@ -165,7 +143,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
label=label,
|
||||
color=color,
|
||||
bottle_names=bottle_names,
|
||||
image_policy=image_policy,
|
||||
)
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
@@ -225,8 +202,6 @@ def _start_headless(
|
||||
label=label,
|
||||
color=args.color or "",
|
||||
bottle_names=bottle_names,
|
||||
headless=True,
|
||||
image_policy="cached" if args.cached_images else "fresh",
|
||||
)
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
@@ -260,7 +235,7 @@ def prepare_with_preflight(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
stage_dir: Path,
|
||||
render_preflight: Callable[[DockerBottlePlan, str], None],
|
||||
render_preflight: Callable[[DockerBottlePlan], None],
|
||||
prompt_yes: Callable[[], bool],
|
||||
dry_run: bool = False,
|
||||
backend_name: str | None = None,
|
||||
@@ -281,7 +256,7 @@ def prepare_with_preflight(
|
||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||
identity = _identity_from_plan(plan)
|
||||
|
||||
render_preflight(plan, backend.name)
|
||||
render_preflight(plan)
|
||||
|
||||
if dry_run:
|
||||
info("dry-run requested; not starting container.")
|
||||
@@ -407,17 +382,9 @@ def _text_prompt_yes() -> bool:
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
|
||||
|
||||
def _select_image_policy() -> str | None:
|
||||
return tui.filter_select(
|
||||
["fresh", "cached"],
|
||||
title="Select image startup mode",
|
||||
)
|
||||
|
||||
|
||||
def _text_render_preflight():
|
||||
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
|
||||
def _render(plan: DockerBottlePlan) -> None:
|
||||
print(file=sys.stderr)
|
||||
print(f"backend: {backend_name}", file=sys.stderr)
|
||||
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
||||
return _render
|
||||
|
||||
@@ -556,38 +523,30 @@ def _launch_bottle(
|
||||
return 0
|
||||
|
||||
backend = get_bottle_backend(backend_name)
|
||||
skip_stale = False
|
||||
while True:
|
||||
try:
|
||||
with backend.launch(plan, skip_stale=skip_stale) as bottle:
|
||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
extra_args: tuple[str, ...] = ()
|
||||
if headless_prompt_text:
|
||||
extra_args = tuple(
|
||||
get_provider(agent_provider_template).headless_prompt(
|
||||
headless_prompt_text
|
||||
)
|
||||
)
|
||||
exit_code = attach_agent(
|
||||
bottle,
|
||||
agent_provider_template=agent_provider_template,
|
||||
startup_args=plan.agent_provision.startup_args + extra_args,
|
||||
with backend.launch(plan) as bottle:
|
||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
extra_args: tuple[str, ...] = ()
|
||||
if headless_prompt_text:
|
||||
extra_args = tuple(
|
||||
get_provider(agent_provider_template).headless_prompt(
|
||||
headless_prompt_text
|
||||
)
|
||||
info(
|
||||
f"session ended (exit {exit_code}); "
|
||||
f"container {bottle.name} will be removed"
|
||||
)
|
||||
if agent_provider_template == "claude":
|
||||
capture_claude_session_state(identity, exit_code)
|
||||
break
|
||||
except StaleImageError as exc:
|
||||
if assume_yes:
|
||||
die(str(exc))
|
||||
sys.stderr.write(f"bot-bottle: {exc}\nLaunch anyway? [y/N] ")
|
||||
sys.stderr.flush()
|
||||
if read_tty_line() not in ("y", "Y", "yes", "YES"):
|
||||
break
|
||||
skip_stale = True
|
||||
)
|
||||
exit_code = attach_agent(
|
||||
bottle,
|
||||
agent_provider_template=agent_provider_template,
|
||||
startup_args=plan.agent_provision.startup_args + extra_args,
|
||||
)
|
||||
info(
|
||||
f"session ended (exit {exit_code}); "
|
||||
f"container {bottle.name} will be removed"
|
||||
)
|
||||
# While the container is still alive: always snapshot the
|
||||
# transcript and — if the agent exited non-zero — mark
|
||||
# the state for preservation. This picks up crashes /
|
||||
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
||||
if agent_provider_template == "claude":
|
||||
capture_claude_session_state(identity, exit_code)
|
||||
return 0
|
||||
finally:
|
||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||
|
||||
@@ -45,7 +45,7 @@ from ..supervise import (
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
list_all_pending_proposals,
|
||||
list_pending_proposals,
|
||||
render_diff,
|
||||
write_audit_entry,
|
||||
write_response,
|
||||
@@ -63,9 +63,10 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueuedProposal:
|
||||
"""A pending proposal from the supervise queue."""
|
||||
"""A pending proposal plus the queue dir it was found in."""
|
||||
|
||||
proposal: Proposal
|
||||
queue_dir: Path
|
||||
|
||||
|
||||
# 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]:
|
||||
"""Collect pending proposals across bottles."""
|
||||
out = [
|
||||
QueuedProposal(proposal=proposal)
|
||||
for proposal in list_all_pending_proposals()
|
||||
]
|
||||
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
|
||||
queue_root = _supervise.bot_bottle_root() / "queue"
|
||||
if not queue_root.is_dir():
|
||||
return []
|
||||
out: list[QueuedProposal] = []
|
||||
for slug_dir in sorted(queue_root.iterdir()):
|
||||
if not slug_dir.is_dir():
|
||||
continue
|
||||
for proposal in list_pending_proposals(slug_dir):
|
||||
out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir))
|
||||
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
||||
return out
|
||||
|
||||
@@ -112,6 +118,7 @@ def _detail_lines(
|
||||
(f"tool: {p.tool}", 0),
|
||||
(f"id: {p.id}", 0),
|
||||
(f"arrived: {p.arrival_timestamp}", 0),
|
||||
(f"queue: {qp.queue_dir}", 0),
|
||||
("", 0),
|
||||
("justification:", 0),
|
||||
]
|
||||
@@ -158,7 +165,7 @@ def approve(
|
||||
notes=notes,
|
||||
final_file=final_file,
|
||||
)
|
||||
write_response(qp.proposal.bottle_slug, response)
|
||||
write_response(qp.queue_dir, response)
|
||||
_write_audit(
|
||||
qp, action=status, notes=notes,
|
||||
diff_before=diff_before, diff_after=diff_after,
|
||||
@@ -172,7 +179,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
notes=reason,
|
||||
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="")
|
||||
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""SQLite-backed bot-bottle configuration store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .db_store import DbStore
|
||||
from .migrations import TableMigrations
|
||||
from .supervise_types import host_db_path
|
||||
except ImportError:
|
||||
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
|
||||
from supervise_types import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
|
||||
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS = 1
|
||||
|
||||
|
||||
class ConfigStore(DbStore):
|
||||
"""SQLite configuration for host-side bot-bottle settings."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
migrations = TableMigrations("config_store", [
|
||||
# v1 — host-side bot-bottle settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_bottle_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
cached_image_stale_warning_days INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
""",
|
||||
])
|
||||
super().__init__(db_path or host_db_path(), migrations)
|
||||
|
||||
def cached_image_stale_warning_days(self) -> int:
|
||||
if not self.db_path.is_file():
|
||||
return DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT cached_image_stale_warning_days
|
||||
FROM bot_bottle_config
|
||||
WHERE id = 1
|
||||
""",
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS
|
||||
try:
|
||||
return int(row["cached_image_stale_warning_days"])
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS
|
||||
|
||||
def set_cached_image_stale_warning_days(self, days: int) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO bot_bottle_config (id, cached_image_stale_warning_days)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
cached_image_stale_warning_days = excluded.cached_image_stale_warning_days
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS",
|
||||
"ConfigStore",
|
||||
]
|
||||
@@ -21,7 +21,7 @@ FROM node:22-slim
|
||||
# to it) works against egress's bumped TLS without the agent needing
|
||||
# local DNS.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep iproute2 dnsutils \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App-specific deps. Python isn't required by claude-code itself
|
||||
|
||||
@@ -23,8 +23,9 @@ from ...agent_provider import (
|
||||
provider_startup_args,
|
||||
)
|
||||
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 .claude_auth import claude_host_access_token
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -115,7 +116,6 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
del forward_host_credentials, host_env
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
startup_args = provider_startup_args(provider_settings)
|
||||
guest_home = self.guest_home
|
||||
@@ -177,13 +177,24 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
claude_settings,
|
||||
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(
|
||||
host="api.anthropic.com",
|
||||
auth_scheme="Bearer" if auth_token else "",
|
||||
token_ref=auth_token,
|
||||
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
|
||||
token_ref=cred_token_ref,
|
||||
),)
|
||||
hidden_env_names: frozenset[str] = frozenset()
|
||||
if auth_token:
|
||||
if auth_token or forward_host_credentials:
|
||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||
|
||||
@@ -205,6 +216,7 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
files=tuple(files),
|
||||
egress_routes=egress_routes,
|
||||
hidden_env_names=hidden_env_names,
|
||||
provisioned_env=provisioned_env,
|
||||
)
|
||||
|
||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -34,12 +34,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
_SUPERVISE_MCP_NAME = "supervise"
|
||||
_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex"
|
||||
_CODEX_CLI_PATH = (
|
||||
"/home/node/.local/bin:"
|
||||
"/home/node/.codex/packages/standalone/current/bin:"
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
)
|
||||
|
||||
|
||||
def _skills_dir(guest_home: str) -> str:
|
||||
@@ -56,7 +50,7 @@ def _prompt_path(guest_home: str) -> str:
|
||||
|
||||
_RUNTIME = AgentProviderRuntime(
|
||||
template="codex",
|
||||
command=_CODEX_CLI,
|
||||
command="codex",
|
||||
image="bot-bottle-codex:latest",
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
@@ -151,8 +145,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
"env",
|
||||
f"HOME={guest_home}",
|
||||
f"CODEX_HOME={auth_dir}",
|
||||
f"PATH={_CODEX_CLI_PATH}",
|
||||
_CODEX_CLI, "login", "status",
|
||||
"codex", "login", "status",
|
||||
), (
|
||||
"codex host credentials: dummy auth was copied into the "
|
||||
"guest, but Codex did not accept it"
|
||||
@@ -274,7 +267,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
return
|
||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||
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)}",
|
||||
user="node",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Scoped forge wrapper: read-anywhere / write-scoped access control.
|
||||
|
||||
`ScopedForge` wraps any forge object and restricts write operations to
|
||||
the set of issue/PR numbers the agent is explicitly assigned to. Read
|
||||
operations always pass through unconditionally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ScopedForge:
|
||||
"""Delegates all forge calls to an inner forge, raising `PermissionError`
|
||||
on write calls for numbers outside the assigned scope."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
forge: Any,
|
||||
*,
|
||||
assigned_issue: int,
|
||||
assigned_prs: list[int],
|
||||
) -> None:
|
||||
self._forge = forge
|
||||
self._allowed_writes: frozenset[int] = frozenset({assigned_issue, *assigned_prs})
|
||||
|
||||
def _check_write(self, number: int) -> None:
|
||||
if number not in self._allowed_writes:
|
||||
raise PermissionError(
|
||||
f"write to #{number} is outside the assigned scope "
|
||||
f"(allowed: {sorted(self._allowed_writes)})"
|
||||
)
|
||||
|
||||
def is_org_member(self, org: str, username: str) -> bool:
|
||||
return self._forge.is_org_member(org, username)
|
||||
|
||||
def read_issue(self, number: int) -> dict[str, Any]:
|
||||
return self._forge.read_issue(number)
|
||||
|
||||
def read_pr(self, number: int) -> dict[str, Any]:
|
||||
return self._forge.read_pr(number)
|
||||
|
||||
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:
|
||||
self._check_write(number)
|
||||
self._forge.post_comment(number, body)
|
||||
|
||||
def update_description(self, number: int, body: str) -> None:
|
||||
self._check_write(number)
|
||||
self._forge.update_description(number, body)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Gitea API client and forge adapter (PRD prd-new: fold orchestrator).
|
||||
|
||||
`GiteaClient` is a thin HTTP wrapper (stdlib `urllib.request` only — no
|
||||
new runtime dependencies). `GiteaForge` composes a client and exposes
|
||||
the forge protocol used by the orchestrator's sidecar and lifecycle.
|
||||
|
||||
Required Gitea token scopes:
|
||||
- Repository: Read & Write (issues, comments, PR descriptions)
|
||||
- Organization: Read (org membership check)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
_TIMEOUT_SECS = 30
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Low-level HTTP wrapper for the Gitea REST API."""
|
||||
|
||||
def __init__(
|
||||
self, *, api_url: str, owner: str, repo: str, token: str
|
||||
) -> None:
|
||||
self._base = api_url.rstrip("/")
|
||||
self._owner = owner
|
||||
self._repo = repo
|
||||
self._headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
url = f"{self._base}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
url, data=data, headers=self._headers, method=method
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
def is_org_member(self, org: str, username: str) -> bool:
|
||||
url = f"{self._base}/orgs/{org}/members/{username}"
|
||||
req = urllib.request.Request(url, headers=self._headers, method="GET")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=_TIMEOUT_SECS).close()
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return False
|
||||
|
||||
def get_issue(self, number: int) -> dict[str, Any]:
|
||||
return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}")
|
||||
|
||||
def get_pull(self, number: int) -> dict[str, Any]:
|
||||
return self._request("GET", f"/repos/{self._owner}/{self._repo}/pulls/{number}")
|
||||
|
||||
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:
|
||||
"""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:
|
||||
self._client = client
|
||||
|
||||
def is_org_member(self, org: str, username: str) -> bool:
|
||||
return self._client.is_org_member(org, username)
|
||||
|
||||
def read_issue(self, number: int) -> dict[str, Any]:
|
||||
return self._client.get_issue(number)
|
||||
|
||||
def read_pr(self, number: int) -> dict[str, Any]:
|
||||
return self._client.get_pull(number)
|
||||
|
||||
def read_comments(self, number: int) -> list[dict[str, Any]]:
|
||||
return self._client.list_comments(number)
|
||||
|
||||
def post_comment(self, number: int, body: str) -> None:
|
||||
self._client.create_comment(number, body)
|
||||
|
||||
def update_description(self, number: int, body: str) -> None:
|
||||
self._client.update_issue(number, body)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Forge state persistence for the orchestrator (PRD prd-new: fold orchestrator).
|
||||
|
||||
`ForgeState` is a dataclass that mirrors the orchestrator's `RunRecord`
|
||||
field-for-field, held here so the store implementation is in bot-bottle
|
||||
where the Gitea contrib lives.
|
||||
|
||||
`SqliteForgeStateStore` backs it with a single SQLite table. The DB path
|
||||
is optional; passing `None` uses `:memory:` (useful for tests and status
|
||||
commands that don't need persistence).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgeState:
|
||||
"""Persisted state for one forge-targeted issue's bottle lifecycle."""
|
||||
|
||||
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 = ""
|
||||
last_checkin_at: str = ""
|
||||
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS forge_state (
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL,
|
||||
bottle_names TEXT NOT NULL DEFAULT '[]',
|
||||
backend_name TEXT NOT NULL DEFAULT '',
|
||||
agent_git_user TEXT NOT NULL DEFAULT '',
|
||||
pr_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
last_checkin_at TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (owner, repo, issue_number)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
class SqliteForgeStateStore:
|
||||
"""SQLite-backed `ForgeState` store.
|
||||
|
||||
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:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO forge_state
|
||||
(owner, repo, issue_number, slug, agent_name,
|
||||
bottle_names, backend_name, agent_git_user,
|
||||
pr_number, status, last_checkin_at)
|
||||
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.repo,
|
||||
state.issue_number,
|
||||
state.slug,
|
||||
state.agent_name,
|
||||
json.dumps(state.bottle_names),
|
||||
state.backend_name,
|
||||
state.agent_git_user,
|
||||
state.pr_number,
|
||||
state.status,
|
||||
state.last_checkin_at,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM forge_state WHERE owner=? AND repo=? AND issue_number=?",
|
||||
(owner, repo, issue_number),
|
||||
).fetchone()
|
||||
return _row_to_state(row) if row is not None else None
|
||||
|
||||
def delete(self, owner: str, repo: str, issue_number: int) -> None:
|
||||
self._conn.execute(
|
||||
"DELETE FROM forge_state WHERE owner=? AND repo=? AND issue_number=?",
|
||||
(owner, repo, issue_number),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def all(self) -> list[ForgeState]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM forge_state ORDER BY owner, repo, issue_number"
|
||||
).fetchall()
|
||||
return [_row_to_state(r) for r in rows]
|
||||
|
||||
|
||||
def _row_to_state(row: sqlite3.Row) -> ForgeState:
|
||||
return ForgeState(
|
||||
owner=row["owner"],
|
||||
repo=row["repo"],
|
||||
issue_number=row["issue_number"],
|
||||
slug=row["slug"],
|
||||
agent_name=row["agent_name"],
|
||||
bottle_names=json.loads(row["bottle_names"]),
|
||||
backend_name=row["backend_name"],
|
||||
agent_git_user=row["agent_git_user"],
|
||||
pr_number=row["pr_number"],
|
||||
status=row["status"],
|
||||
last_checkin_at=row["last_checkin_at"],
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -23,13 +23,13 @@ from .egress_addon_core import (
|
||||
PathMatch as CorePathMatch,
|
||||
Route,
|
||||
)
|
||||
from .errors import MissingEnvVarError
|
||||
from .log import die
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import ManifestBottle
|
||||
|
||||
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"
|
||||
|
||||
@@ -355,18 +355,16 @@ def egress_resolve_token_values(
|
||||
for token_env, token_ref in token_env_map.items():
|
||||
value = host_env.get(token_ref)
|
||||
if value is None:
|
||||
raise MissingEnvVarError(
|
||||
token_ref,
|
||||
die(
|
||||
f"egress: host env var '{token_ref}' is unset. Set it "
|
||||
f"before launching, or remove the corresponding auth block "
|
||||
f"from bottle.egress.routes.",
|
||||
f"from bottle.egress.routes."
|
||||
)
|
||||
if not value:
|
||||
raise MissingEnvVarError(
|
||||
token_ref,
|
||||
die(
|
||||
f"egress: host env var '{token_ref}' is empty. The "
|
||||
f"egress will not inject an empty token; set it to "
|
||||
f"the real value or remove the route's auth block.",
|
||||
f"the real value or remove the route's auth block."
|
||||
)
|
||||
out[token_env] = value
|
||||
return out
|
||||
@@ -400,6 +398,7 @@ class Egress(ABC):
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
|
||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||
"EGRESS_HOSTNAME",
|
||||
"EGRESS_ROUTES_FILENAME",
|
||||
|
||||
@@ -79,13 +79,14 @@ class EgressAddon:
|
||||
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||
# runs the addon hooks, so no lock is needed.
|
||||
self.safe_tokens: set[str] = set()
|
||||
self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip()
|
||||
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||
self._reload(initial=True)
|
||||
self._install_sighup()
|
||||
|
||||
def _supervise_available(self) -> bool:
|
||||
return bool(self._supervise_slug)
|
||||
return bool(self._supervise_queue_dir and self._supervise_slug)
|
||||
|
||||
def _reload(self, *, initial: bool = False) -> None:
|
||||
try:
|
||||
@@ -392,8 +393,9 @@ class EgressAddon:
|
||||
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||
current_file_hash=_sv.sha256_hex(payload),
|
||||
)
|
||||
queue_dir = Path(self._supervise_queue_dir)
|
||||
try:
|
||||
_sv.write_proposal(proposal)
|
||||
_sv.write_proposal(queue_dir, proposal)
|
||||
except OSError as e:
|
||||
sys.stderr.write(
|
||||
f"egress: could not queue token-allow proposal: {e}; "
|
||||
@@ -409,8 +411,8 @@ class EgressAddon:
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
|
||||
response = await self._await_token_response(proposal.id)
|
||||
_sv.archive_proposal(self._supervise_slug, proposal.id)
|
||||
response = await self._await_token_response(queue_dir, proposal.id)
|
||||
_sv.archive_proposal(queue_dir, proposal.id)
|
||||
|
||||
if response is not None and response.status in (
|
||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||
@@ -437,15 +439,16 @@ class EgressAddon:
|
||||
|
||||
async def _await_token_response(
|
||||
self,
|
||||
queue_dir: Path,
|
||||
proposal_id: str,
|
||||
) -> "_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."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self._token_allow_timeout
|
||||
while True:
|
||||
try:
|
||||
return _sv.read_response(self._supervise_slug, proposal_id)
|
||||
return _sv.read_response(queue_dir, proposal_id)
|
||||
except (OSError, ValueError, KeyError):
|
||||
# Not written yet, or a partial/malformed write — retry until
|
||||
# the deadline, then fail closed.
|
||||
|
||||
+2
-4
@@ -35,7 +35,6 @@ import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .errors import MissingEnvVarError
|
||||
from .log import die
|
||||
from .manifest import Manifest
|
||||
|
||||
@@ -137,10 +136,9 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv:
|
||||
host_var = env_entry_interpolated_from(raw)
|
||||
host_value = os.environ.get(host_var, "")
|
||||
if not host_value:
|
||||
raise MissingEnvVarError(
|
||||
host_var,
|
||||
die(
|
||||
f"env entry {name} is interpolated from ${host_var}, "
|
||||
f"but ${host_var} is unset or empty in the host environment.",
|
||||
f"but ${host_var} is unset or empty in the host environment."
|
||||
)
|
||||
forwarded[name] = host_value
|
||||
else: # literal
|
||||
|
||||
@@ -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)
|
||||
@@ -82,7 +82,6 @@ class GitGatePlan:
|
||||
|
||||
|
||||
|
||||
|
||||
class GitGate(ABC):
|
||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||
(upstream lift + entrypoint/hook render); the sidecar's
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -13,7 +13,6 @@ import dataclasses
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .errors import MissingEnvVarError
|
||||
from .log import info
|
||||
from .manifest import ManifestBottle, ManifestGitEntry
|
||||
from .git_gate_render import GitGateUpstream
|
||||
@@ -35,10 +34,9 @@ def _provision_dynamic_key(
|
||||
pk = entry.Key
|
||||
token = os.environ.get(pk.forge_token_env)
|
||||
if token is None:
|
||||
raise MissingEnvVarError(
|
||||
pk.forge_token_env,
|
||||
raise RuntimeError(
|
||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||
f" = {pk.forge_token_env!r}: env var is not set",
|
||||
f" = {pk.forge_token_env!r}: env var is not set"
|
||||
)
|
||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||
@@ -80,11 +78,10 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
|
||||
key_id = id_file.read_text().strip()
|
||||
token = os.environ.get(pk.forge_token_env)
|
||||
if token is None:
|
||||
raise MissingEnvVarError(
|
||||
pk.forge_token_env,
|
||||
raise RuntimeError(
|
||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||
f" = {pk.forge_token_env!r}: env var is not set;"
|
||||
f" cannot revoke deploy key {key_id}",
|
||||
f" cannot revoke deploy key {key_id}"
|
||||
)
|
||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||
|
||||
@@ -228,22 +228,19 @@ supervise_gitleaks_allow() {
|
||||
fi
|
||||
|
||||
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 hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import supervise as _sv
|
||||
except ImportError:
|
||||
from bot_bottle import supervise as _sv
|
||||
|
||||
report_path = Path(sys.argv[1])
|
||||
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||
if not slug:
|
||||
if not queue_dir or not slug:
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
@@ -280,19 +277,31 @@ for i, finding in enumerate(raw, 1):
|
||||
])
|
||||
|
||||
payload = "\n".join(lines).rstrip() + "\n"
|
||||
proposal = _sv.Proposal.new(
|
||||
bottle_slug=slug,
|
||||
tool=_sv.TOOL_GITLEAKS_ALLOW,
|
||||
proposed_file=payload,
|
||||
justification=(
|
||||
proposal_id = str(uuid.uuid4())
|
||||
proposal = {
|
||||
"id": proposal_id,
|
||||
"bottle_slug": slug,
|
||||
"tool": "gitleaks-allow",
|
||||
"proposed_file": payload,
|
||||
"justification": (
|
||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||
"approve only for dummy test fixtures or confirmed false positives"
|
||||
),
|
||||
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
now=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
_sv.write_proposal(proposal)
|
||||
print(proposal.id)
|
||||
"arrival_timestamp": datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
queue = Path(queue_dir)
|
||||
queue.mkdir(parents=True, exist_ok=True)
|
||||
path = queue / f"{proposal_id}.proposal.json"
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(proposal, f, indent=2)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
print(proposal_id)
|
||||
PY
|
||||
)
|
||||
rc=$?
|
||||
@@ -305,7 +314,8 @@ PY
|
||||
return 1
|
||||
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}
|
||||
case "$timeout" in
|
||||
''|*[!0-9]*)
|
||||
@@ -317,41 +327,26 @@ PY
|
||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||
waited=0
|
||||
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
|
||||
|
||||
try:
|
||||
import supervise as _sv
|
||||
except ImportError:
|
||||
from bot_bottle import supervise as _sv
|
||||
|
||||
slug = sys.argv[1]
|
||||
try:
|
||||
response = _sv.read_response(slug, sys.argv[2])
|
||||
except FileNotFoundError:
|
||||
sys.exit(2)
|
||||
print(response.status)
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(1)
|
||||
status = raw.get("status")
|
||||
if not isinstance(status, str):
|
||||
sys.exit(1)
|
||||
print(status)
|
||||
PY
|
||||
)
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
status=""
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
status="invalid"
|
||||
fi
|
||||
if [ -n "$status" ]; then
|
||||
) || status=""
|
||||
case "$status" in
|
||||
approved|modified)
|
||||
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' || true
|
||||
import sys
|
||||
|
||||
try:
|
||||
import supervise as _sv
|
||||
except ImportError:
|
||||
from bot_bottle import supervise as _sv
|
||||
|
||||
_sv.archive_proposal(sys.argv[1], sys.argv[2])
|
||||
PY
|
||||
mkdir -p "$queue_dir/processed"
|
||||
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||
return 0
|
||||
;;
|
||||
@@ -504,3 +499,4 @@ if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Shared helpers for cached-image quickstart stale checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .config_store import ConfigStore
|
||||
except ImportError:
|
||||
from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
|
||||
|
||||
class StaleImageError(Exception):
|
||||
"""Raised when a cached image or artifact exceeds the configured staleness
|
||||
threshold. Callers can catch this to prompt interactively; headless paths
|
||||
let it propagate as a fatal error."""
|
||||
|
||||
|
||||
def check_stale(label: str, created_at: datetime) -> None:
|
||||
"""Raise StaleImageError if `created_at` is older than the configured
|
||||
stale-warning threshold. Negative threshold disables the check."""
|
||||
threshold_days = ConfigStore().cached_image_stale_warning_days()
|
||||
if threshold_days < 0:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
created = created_at.astimezone(timezone.utc)
|
||||
age = now - created
|
||||
if age.total_seconds() <= threshold_days * 86400:
|
||||
return
|
||||
raise StaleImageError(
|
||||
f"cached {label} is {age.days} day(s) old; "
|
||||
"quickstart does not verify it matches the current Dockerfile/context"
|
||||
)
|
||||
|
||||
|
||||
def check_stale_path(label: str, path: Path) -> None:
|
||||
"""Raise StaleImageError if `path`'s mtime exceeds the staleness threshold."""
|
||||
check_stale(label, datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc))
|
||||
|
||||
|
||||
__all__ = ["StaleImageError", "check_stale", "check_stale_path"]
|
||||
@@ -25,8 +25,9 @@ class ManifestAgentProvider:
|
||||
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
||||
so the Claude Code CLI starts.
|
||||
|
||||
`forward_host_credentials` forwards the host Codex auth token into
|
||||
the egress sidecar (Codex only).
|
||||
`forward_host_credentials` forwards the host provider auth token into
|
||||
the egress sidecar (Codex and Claude). For Codex this reads
|
||||
`~/.codex/auth.json`; for Claude it reads `~/.claude.json`.
|
||||
"""
|
||||
|
||||
template: str = "claude"
|
||||
@@ -92,10 +93,15 @@ class ManifestAgentProvider:
|
||||
f"is only supported for built-in 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(
|
||||
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"))
|
||||
return cls(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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, ...]
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
@@ -1,64 +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 .config_store import ConfigStore
|
||||
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 config_store import ConfigStore # 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()
|
||||
and ConfigStore(self.db_path).is_migrated()
|
||||
)
|
||||
|
||||
def migrate(self) -> None:
|
||||
QueueStore("", self.db_path).migrate()
|
||||
AuditStore(self.db_path).migrate()
|
||||
ConfigStore(self.db_path).migrate()
|
||||
|
||||
|
||||
__all__ = ["StoreManager"]
|
||||
+351
-114
@@ -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
|
||||
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
|
||||
(bot_bottle.cli.supervise) sees the proposal, accepts
|
||||
approve / modify / reject, and writes a response row. The sidecar sees
|
||||
the response and returns `{status, notes}` to the agent.
|
||||
approve / modify / reject, and writes a response file alongside the
|
||||
proposal. The sidecar sees the response and returns `{status, notes}`
|
||||
to the agent.
|
||||
|
||||
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
|
||||
bot_bottle/supervise_server.py; the supervise daemon's container
|
||||
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
|
||||
|
||||
import dataclasses
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .supervise_types import (
|
||||
ACTION_OPERATOR_EDIT,
|
||||
AuditEntry,
|
||||
HOST_DB_FILENAME,
|
||||
Proposal,
|
||||
Response,
|
||||
STATUSES,
|
||||
STATUS_APPROVED,
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOLS,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
bot_bottle_root,
|
||||
)
|
||||
except ImportError:
|
||||
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
||||
ACTION_OPERATOR_EDIT,
|
||||
AuditEntry,
|
||||
HOST_DB_FILENAME,
|
||||
Proposal,
|
||||
Response,
|
||||
STATUSES,
|
||||
STATUS_APPROVED,
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOLS,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
bot_bottle_root,
|
||||
)
|
||||
|
||||
|
||||
SUPERVISE_HOSTNAME = "supervise"
|
||||
SUPERVISE_PORT = 9100
|
||||
|
||||
TOOL_EGRESS_BLOCK = "egress-block"
|
||||
TOOL_EGRESS_ALLOW = "egress-allow"
|
||||
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||
# Written directly by the egress addon (not an agent-facing MCP tool) when an
|
||||
# outbound DLP token block is routed to the operator for override (PRD 0062).
|
||||
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||
TOOLS: tuple[str, ...] = (
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
|
||||
# The supervise sidecar uses these to query egress's
|
||||
# introspection endpoint for the `list-egress-routes` MCP
|
||||
# tool. The hostname + port match egress's docker network
|
||||
@@ -95,77 +77,236 @@ COMPONENT_FOR_TOOL: dict[str, str] = {
|
||||
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
|
||||
|
||||
|
||||
# --- Paths -----------------------------------------------------------------
|
||||
|
||||
|
||||
def bot_bottle_root() -> Path:
|
||||
return Path.home() / ".bot-bottle"
|
||||
|
||||
|
||||
def queue_dir_for_slug(slug: str) -> Path:
|
||||
return bot_bottle_root() / "queue" / slug
|
||||
|
||||
|
||||
def audit_dir() -> Path:
|
||||
return bot_bottle_root() / "audit"
|
||||
|
||||
|
||||
def host_db_path() -> Path:
|
||||
# Calls bot_bottle_root() through this module's globals so that patches
|
||||
# on supervise.bot_bottle_root propagate to callers going through
|
||||
# supervise.host_db_path().
|
||||
#
|
||||
# Kept in its own "db" subdirectory (see supervise_types.host_db_path
|
||||
# for why) — must stay in sync with that copy.
|
||||
return bot_bottle_root() / "db" / HOST_DB_FILENAME
|
||||
|
||||
|
||||
def audit_log_path(component: str, slug: str) -> Path:
|
||||
return audit_dir() / f"{component}-{slug}.log"
|
||||
|
||||
|
||||
try:
|
||||
from .queue_store import QueueStore
|
||||
from .audit_store import AuditStore
|
||||
from .store_manager import StoreManager
|
||||
except ImportError:
|
||||
# Sidecar bundle: files are flat-copied under /app, not a package.
|
||||
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
# --- Dataclasses -----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Proposal:
|
||||
"""One pending tool-call from the agent. The sidecar writes one
|
||||
of these to the queue dir on a tool call; the operator's TUI
|
||||
reads them; the sidecar polls for a matching Response."""
|
||||
|
||||
id: str
|
||||
bottle_slug: str
|
||||
tool: str
|
||||
proposed_file: str
|
||||
justification: str
|
||||
arrival_timestamp: str
|
||||
current_file_hash: str
|
||||
|
||||
@classmethod
|
||||
def new(
|
||||
cls,
|
||||
*,
|
||||
bottle_slug: str,
|
||||
tool: str,
|
||||
proposed_file: str,
|
||||
justification: str,
|
||||
current_file_hash: str,
|
||||
now: datetime | None = None,
|
||||
) -> "Proposal":
|
||||
ts = (now or datetime.now(timezone.utc)).isoformat()
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
bottle_slug=bottle_slug,
|
||||
tool=tool,
|
||||
proposed_file=proposed_file,
|
||||
justification=justification,
|
||||
arrival_timestamp=ts,
|
||||
current_file_hash=current_file_hash,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, object]) -> "Proposal":
|
||||
tool = _require_str(raw, "tool")
|
||||
if tool not in TOOLS:
|
||||
raise ValueError(f"tool must be one of {TOOLS}; got {tool!r}")
|
||||
return cls(
|
||||
id=_require_str(raw, "id"),
|
||||
bottle_slug=_require_str(raw, "bottle_slug"),
|
||||
tool=tool,
|
||||
proposed_file=_require_str(raw, "proposed_file"),
|
||||
justification=_require_str(raw, "justification"),
|
||||
arrival_timestamp=_require_str(raw, "arrival_timestamp"),
|
||||
current_file_hash=_require_str(raw, "current_file_hash"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
"""The operator's decision on a proposal. The TUI writes one of
|
||||
these to the queue dir; the sidecar reads it and returns the
|
||||
`{status, notes}` pair to the agent's tool call.
|
||||
|
||||
`final_file` carries the file content the supervisor will
|
||||
actually apply: for `approved`, equal to the proposal's
|
||||
`proposed_file`; for `modified`, the operator's edited version
|
||||
(the audit diff is current → final_file, not current →
|
||||
proposed_file); for `rejected`, None."""
|
||||
|
||||
proposal_id: str
|
||||
status: str
|
||||
notes: str
|
||||
final_file: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, object]) -> "Response":
|
||||
status = _require_str(raw, "status")
|
||||
if status not in STATUSES:
|
||||
raise ValueError(
|
||||
f"response status must be one of {STATUSES}; got {status!r}"
|
||||
)
|
||||
final = raw.get("final_file")
|
||||
if final is not None and not isinstance(final, str):
|
||||
raise ValueError(
|
||||
f"final_file must be a string or null; got {type(final).__name__}"
|
||||
)
|
||||
return cls(
|
||||
proposal_id=_require_str(raw, "proposal_id"),
|
||||
status=status,
|
||||
notes=_require_str(raw, "notes"),
|
||||
final_file=final,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEntry:
|
||||
"""One row of the per-bottle audit log. JSON-Lines, append-only."""
|
||||
|
||||
timestamp: str
|
||||
bottle_slug: str
|
||||
component: str
|
||||
operator_action: str
|
||||
operator_notes: str
|
||||
justification: str
|
||||
diff: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
# --- Queue I/O -------------------------------------------------------------
|
||||
|
||||
|
||||
def write_proposal(proposal: Proposal) -> Path:
|
||||
"""Persist `proposal` in the queue database, mode 0o600.
|
||||
def _proposal_filename(proposal_id: str) -> str:
|
||||
return f"{proposal_id}.proposal.json"
|
||||
|
||||
|
||||
def _response_filename(proposal_id: str) -> str:
|
||||
return f"{proposal_id}.response.json"
|
||||
|
||||
|
||||
def _id_from_proposal_filename(path: Path) -> str | None:
|
||||
name = path.name
|
||||
if not name.endswith(".proposal.json"):
|
||||
return None
|
||||
return name[: -len(".proposal.json")]
|
||||
|
||||
|
||||
def write_proposal(queue_dir: Path, proposal: Proposal) -> Path:
|
||||
"""Persist `proposal` as JSON in the queue dir, mode 0o600.
|
||||
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:
|
||||
return QueueStore(bottle_slug).read_proposal(proposal_id)
|
||||
def read_proposal(queue_dir: Path, proposal_id: str) -> Proposal:
|
||||
path = queue_dir / _proposal_filename(proposal_id)
|
||||
with path.open() as f:
|
||||
raw = json.load(f)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{path}: top-level must be an object")
|
||||
return Proposal.from_dict(raw)
|
||||
|
||||
|
||||
def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
|
||||
"""All proposals for `bottle_slug` that do not yet have a matching
|
||||
response. Sorted by `arrival_timestamp` so the operator
|
||||
def list_pending_proposals(queue_dir: Path) -> list[Proposal]:
|
||||
"""All proposals in `queue_dir` that do not yet have a matching
|
||||
response file. Sorted by `arrival_timestamp` so the operator
|
||||
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]:
|
||||
"""All pending proposals across bottles, sorted FIFO."""
|
||||
return QueueStore("").list_all_pending_proposals()
|
||||
def write_response(queue_dir: Path, response: Response) -> Path:
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = queue_dir / _response_filename(response.proposal_id)
|
||||
payload = json.dumps(response.to_dict(), indent=2) + "\n"
|
||||
_atomic_write(path, payload, mode=0o600)
|
||||
return path
|
||||
|
||||
|
||||
def write_response(bottle_slug: str, response: Response) -> Path:
|
||||
return QueueStore(bottle_slug).write_response(response)
|
||||
|
||||
|
||||
def read_response(bottle_slug: str, proposal_id: str) -> Response:
|
||||
return QueueStore(bottle_slug).read_response(proposal_id)
|
||||
def read_response(queue_dir: Path, proposal_id: str) -> Response:
|
||||
path = queue_dir / _response_filename(proposal_id)
|
||||
with path.open() as f:
|
||||
raw = json.load(f)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{path}: top-level must be an object")
|
||||
return Response.from_dict(raw)
|
||||
|
||||
|
||||
def wait_for_response(
|
||||
bottle_slug: str,
|
||||
queue_dir: Path,
|
||||
proposal_id: str,
|
||||
*,
|
||||
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
|
||||
@@ -176,35 +317,90 @@ def wait_for_response(
|
||||
which the wait raises TimeoutError. None waits forever — the
|
||||
natural shape, since the operator's response time is unbounded.
|
||||
|
||||
Polls SQLite so the implementation stays portable and stdlib-only."""
|
||||
store = QueueStore(bottle_slug)
|
||||
Polls the filesystem so the implementation stays portable and
|
||||
stdlib-only."""
|
||||
path = queue_dir / _response_filename(proposal_id)
|
||||
while True:
|
||||
try:
|
||||
return store.read_response(proposal_id)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if path.exists():
|
||||
try:
|
||||
with path.open() as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
raw = None
|
||||
if isinstance(raw, dict):
|
||||
try:
|
||||
return Response.from_dict(raw)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"no response for proposal {proposal_id!r}")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
|
||||
"""Mark both proposal and response rows processed.
|
||||
Idempotent — missing rows are silently skipped."""
|
||||
QueueStore(bottle_slug).archive_proposal(proposal_id)
|
||||
def archive_proposal(queue_dir: Path, proposal_id: str) -> None:
|
||||
"""Move both proposal and response files to `<queue_dir>/processed/`.
|
||||
Idempotent — missing files are silently skipped."""
|
||||
processed = queue_dir / "processed"
|
||||
processed.mkdir(parents=True, exist_ok=True)
|
||||
for name in (_proposal_filename(proposal_id), _response_filename(proposal_id)):
|
||||
src = queue_dir / name
|
||||
if src.exists():
|
||||
src.rename(processed / name)
|
||||
|
||||
|
||||
# --- Audit log -------------------------------------------------------------
|
||||
|
||||
|
||||
def write_audit_entry(entry: AuditEntry) -> Path:
|
||||
"""Append `entry` to the host supervise audit table."""
|
||||
return AuditStore().write_audit_entry(entry)
|
||||
"""Append `entry` as one JSON-Lines record to the per-bottle
|
||||
audit log. Acquires an advisory exclusive lock so concurrent
|
||||
writers don't interleave bytes."""
|
||||
path = audit_log_path(entry.component, entry.bottle_slug)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(entry.to_dict(), sort_keys=False) + "\n"
|
||||
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
||||
try:
|
||||
_try_flock(fd)
|
||||
try:
|
||||
os.write(fd, line.encode("utf-8"))
|
||||
finally:
|
||||
_try_funlock(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
|
||||
"""Load all audit entries for the given component+slug."""
|
||||
return AuditStore().read_audit_entries(component, slug)
|
||||
"""Load all audit entries for the given component+slug. Empty
|
||||
list if the log doesn't exist."""
|
||||
path = audit_log_path(component, slug)
|
||||
if not path.is_file():
|
||||
return []
|
||||
out: list[AuditEntry] = []
|
||||
with path.open() as f:
|
||||
for raw_line in f:
|
||||
raw_line = raw_line.strip()
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
try:
|
||||
out.append(AuditEntry(
|
||||
timestamp=_require_str(raw, "timestamp"),
|
||||
bottle_slug=_require_str(raw, "bottle_slug"),
|
||||
component=_require_str(raw, "component"),
|
||||
operator_action=_require_str(raw, "operator_action"),
|
||||
operator_notes=_require_str(raw, "operator_notes"),
|
||||
justification=_require_str(raw, "justification"),
|
||||
diff=_require_str(raw, "diff"),
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
# --- Diff rendering --------------------------------------------------------
|
||||
@@ -237,47 +433,89 @@ def sha256_hex(content: str) -> str:
|
||||
class SupervisePlan:
|
||||
"""Output of Supervise.prepare; consumed by .start.
|
||||
|
||||
`db_path` is the host database bind-mounted into the sidecar at
|
||||
/run/supervise/bot-bottle.db. `internal_network` is empty at
|
||||
prepare time; the backend's launch step fills it via
|
||||
dataclasses.replace before calling .start."""
|
||||
`queue_dir` is the host directory bind-mounted into the sidecar
|
||||
at /run/supervise/queue. `internal_network` is empty at prepare
|
||||
time; the backend's launch step fills it via dataclasses.replace
|
||||
before calling .start."""
|
||||
|
||||
slug: str
|
||||
db_path: Path
|
||||
queue_dir: Path
|
||||
internal_network: str = ""
|
||||
|
||||
|
||||
class Supervise(ABC):
|
||||
"""Per-bottle supervise sidecar. Encapsulates host-side database
|
||||
staging; the sidecar's start/stop lifecycle is backend-specific."""
|
||||
"""Per-bottle supervise sidecar. Encapsulates the host-side
|
||||
prepare (queue dir staging); the sidecar's start/stop lifecycle
|
||||
is backend-specific."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
) -> SupervisePlan:
|
||||
"""Stage the host database. Returns the plan; `internal_network`
|
||||
must be set by the launch step before .start runs."""
|
||||
"""Stage the per-bottle queue dir on the host. Returns the
|
||||
plan; `internal_network` must be set by the launch step before
|
||||
.start runs."""
|
||||
del stage_dir
|
||||
mgr = StoreManager.instance()
|
||||
mgr.migrate()
|
||||
queue_dir = queue_dir_for_slug(slug)
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
return SupervisePlan(
|
||||
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__ = [
|
||||
"ACTION_OPERATOR_EDIT",
|
||||
"AuditEntry",
|
||||
"AuditStore",
|
||||
"COMPONENT_FOR_TOOL",
|
||||
"DEFAULT_POLL_INTERVAL_SEC",
|
||||
"DB_PATH_IN_CONTAINER",
|
||||
"Proposal",
|
||||
"QueueStore",
|
||||
"QUEUE_DIR_IN_CONTAINER",
|
||||
"Response",
|
||||
"StoreManager",
|
||||
"STATUSES",
|
||||
"STATUS_APPROVED",
|
||||
"STATUS_MODIFIED",
|
||||
@@ -298,9 +536,8 @@ __all__ = [
|
||||
"audit_dir",
|
||||
"audit_log_path",
|
||||
"bot_bottle_root",
|
||||
"host_db_path",
|
||||
"list_pending_proposals",
|
||||
"list_all_pending_proposals",
|
||||
"queue_dir_for_slug",
|
||||
"read_audit_entries",
|
||||
"read_proposal",
|
||||
"read_response",
|
||||
|
||||
@@ -7,13 +7,14 @@ config changes when stuck. The tools are `egress-allow`,
|
||||
Each queued tool call:
|
||||
|
||||
1. Validates the proposed file syntactically.
|
||||
2. Writes a Proposal to the host SQLite database.
|
||||
3. Blocks polling for a matching Response row.
|
||||
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
|
||||
the host's ~/.bot-bottle/queue/<slug>/).
|
||||
3. Blocks polling for a matching Response file.
|
||||
4. Returns the operator's `{status, notes}` to the agent.
|
||||
|
||||
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
|
||||
container creation by the backend's start step). SUPERVISE_DB_PATH
|
||||
points at the bind-mounted host database.
|
||||
container creation by the backend's start step). The queue dir comes
|
||||
from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`).
|
||||
|
||||
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
||||
|
||||
@@ -41,6 +42,7 @@ import typing
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
# Same-directory imports inside the bundle container; these files are
|
||||
@@ -275,6 +277,7 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
||||
@dataclass(frozen=True)
|
||||
class ServerConfig:
|
||||
bottle_slug: str
|
||||
queue_dir: Path
|
||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
@@ -373,7 +376,7 @@ def handle_tools_call(
|
||||
current_file_hash=_sv.sha256_hex(proposed_file),
|
||||
)
|
||||
try:
|
||||
_sv.write_proposal(proposal)
|
||||
_sv.write_proposal(config.queue_dir, proposal)
|
||||
except OSError as e:
|
||||
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
|
||||
sys.stderr.write(
|
||||
@@ -384,7 +387,7 @@ def handle_tools_call(
|
||||
deadline = time.monotonic() + config.response_timeout_seconds
|
||||
try:
|
||||
response = _sv.wait_for_response(
|
||||
config.bottle_slug,
|
||||
config.queue_dir,
|
||||
proposal.id,
|
||||
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
|
||||
deadline=deadline,
|
||||
@@ -396,7 +399,7 @@ def handle_tools_call(
|
||||
"isError": False,
|
||||
}
|
||||
try:
|
||||
_sv.archive_proposal(config.bottle_slug, proposal.id)
|
||||
_sv.archive_proposal(config.queue_dir, proposal.id)
|
||||
except OSError as 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):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
config: ServerConfig = ServerConfig(bottle_slug="")
|
||||
config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path())
|
||||
|
||||
|
||||
# --- Entry point -----------------------------------------------------------
|
||||
@@ -545,18 +548,21 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
def serve(
|
||||
*,
|
||||
bottle_slug: str,
|
||||
queue_dir: Path,
|
||||
port: int = _sv.SUPERVISE_PORT,
|
||||
bind: str = "0.0.0.0",
|
||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> typing.NoReturn:
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
server = MCPServer((bind, port), MCPHandler)
|
||||
server.config = ServerConfig(
|
||||
bottle_slug=bottle_slug,
|
||||
queue_dir=queue_dir,
|
||||
response_timeout_seconds=response_timeout_seconds,
|
||||
)
|
||||
sys.stderr.write(
|
||||
f"supervise listening on {bind}:{port}; "
|
||||
f"slug={bottle_slug!r}; "
|
||||
f"slug={bottle_slug!r}; queue={queue_dir}; "
|
||||
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
|
||||
)
|
||||
sys.stderr.flush()
|
||||
@@ -575,6 +581,7 @@ def main(argv: list[str]) -> int:
|
||||
if not bottle_slug:
|
||||
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
||||
return 2
|
||||
queue_dir = Path(os.environ.get("SUPERVISE_QUEUE_DIR", _sv.QUEUE_DIR_IN_CONTAINER))
|
||||
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
|
||||
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
|
||||
try:
|
||||
@@ -584,6 +591,7 @@ def main(argv: list[str]) -> int:
|
||||
return 2
|
||||
serve(
|
||||
bottle_slug=bottle_slug,
|
||||
queue_dir=queue_dir,
|
||||
port=port,
|
||||
bind=bind,
|
||||
response_timeout_seconds=response_timeout_seconds,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -21,11 +21,6 @@ Public API:
|
||||
For a Markdown file with YAML frontmatter delimited by `---`
|
||||
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):
|
||||
|
||||
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)
|
||||
body = text[body_start:]
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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,115 +0,0 @@
|
||||
# PRD prd-new: smolmachines sidecar VM
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** codex
|
||||
- **Created:** 2026-07-09
|
||||
- **Issue:** #332
|
||||
|
||||
## Summary
|
||||
|
||||
Run the smolmachines backend's trusted sidecar bundle as its own smolVM instead
|
||||
of a Docker container. A bottle then consists of an agent VM plus a sidecar VM,
|
||||
with the agent VM's TSI allowlist limited to the per-bottle agent-facing
|
||||
sidecar surface.
|
||||
|
||||
## Problem
|
||||
|
||||
The smolmachines backend currently runs the agent in smolVM but keeps
|
||||
egress/git-gate/supervise in a Docker sidecar bundle. That hybrid launch path
|
||||
keeps Docker in the trusted runtime path and leaves smolmachines with a
|
||||
different isolation boundary than its agent VM design suggests.
|
||||
|
||||
The existing Docker sidecar path also uses Docker port publishing to bind only
|
||||
agent-facing services to the per-bottle host address. TSI is IP-only, so the
|
||||
smolVM replacement must preserve that scoping: publishing sidecar services on
|
||||
generic host localhost would let the agent reach unrelated host services or
|
||||
other bottle sidecars if those services share the allowed address.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` runs with both the
|
||||
agent and sidecar as smolVMs.
|
||||
2. The smolmachines launch path no longer uses `docker run` for the sidecar
|
||||
bundle.
|
||||
3. Agent-facing egress, git-gate, and supervise endpoints are exposed only
|
||||
through the bottle's own published sidecar surface.
|
||||
4. Internal sidecar-only ports are not reachable from the agent VM.
|
||||
5. The agent VM TSI allowlist remains fail-closed and limited to the per-bottle
|
||||
address.
|
||||
6. Sidecar config, secrets, and state are delivered to the sidecar VM without
|
||||
exposing provider or forge credentials to the agent VM.
|
||||
7. Teardown removes both VMs and any host-side published port state.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Rewriting the egress, git-gate, or supervise daemons.
|
||||
- Changing the Docker backend's sidecar bundle behavior.
|
||||
- Replacing the existing agent image build and pack pipeline except where shared
|
||||
helper extraction is needed for sidecar image packing.
|
||||
- Weakening the current smolmachines TSI allowlist checks.
|
||||
|
||||
## Design
|
||||
|
||||
The sidecar VM continues to use the existing sidecar bundle image and
|
||||
`/app/sidecar_init.py` supervisor. The launch flow builds the sidecar image,
|
||||
packs it into a `.smolmachine` artifact, creates a per-bottle sidecar VM from
|
||||
that artifact, passes the same daemon-selection environment the Docker bundle
|
||||
uses today, mounts or copies the same daemon-private config/state inputs, starts
|
||||
the sidecar VM, wraps the sidecar VM's raw smolVM-published ports with a local
|
||||
per-bottle forwarder, then starts the agent VM with
|
||||
`--allow-cidr <per-bottle-address>/32`.
|
||||
|
||||
smolVM currently exposes guest ports as `HOST:GUEST` port pairs bound on host
|
||||
loopback. It does not expose an address-scoped bind like
|
||||
`127.0.0.16:<port>:9099`. Because TSI is IP-only, bot-bottle must not advertise
|
||||
those raw loopback ports directly to the agent. Instead, bot-bottle runs one
|
||||
small stdlib Python TCP forwarder process per bottle:
|
||||
|
||||
```text
|
||||
agent VM
|
||||
-> TSI allows only <bottle-address>/32
|
||||
-> bot-bottle forwarder bound to <bottle-address>:<random-port>
|
||||
-> raw smolVM-published sidecar port on 127.0.0.1:<raw-port>
|
||||
-> sidecar VM service
|
||||
```
|
||||
|
||||
Agent-facing URLs are stamped from the forwarder ports, never the raw smolVM
|
||||
ports:
|
||||
|
||||
- `HTTP_PROXY` / `HTTPS_PROXY` point at the published egress proxy port.
|
||||
- `GIT_GATE_URL` points at the published git HTTP port when git-gate is enabled.
|
||||
- `MCP_SUPERVISE_URL` points at the published supervise port when supervise is
|
||||
enabled.
|
||||
|
||||
Internal sidecar-only services stay bound inside the sidecar VM and are not
|
||||
published.
|
||||
|
||||
### Forwarder constraints
|
||||
|
||||
- Reject wildcard listener binds (`0.0.0.0`, `::`) and generic localhost
|
||||
listener binds (`127.0.0.1`, `::1`) for agent-facing forwards.
|
||||
- Accept only fixed loopback targets selected by launch; the client cannot
|
||||
choose or influence the forwarding target.
|
||||
- Forward bytes transparently without parsing HTTP, CONNECT, TLS, Git, or MCP.
|
||||
- Log lifecycle and endpoint metadata only; never log payload bytes.
|
||||
- Create forwards only for egress, git HTTP when enabled, and supervise when
|
||||
enabled.
|
||||
- Fail closed if any listener cannot bind exactly the requested per-bottle
|
||||
address.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. Extract the existing image-to-smolmachine pack helper so agent and sidecar
|
||||
artifacts share the same cache and registry path.
|
||||
2. Add a strict stdlib host TCP forwarder for per-bottle address-bound
|
||||
forwarding to raw smolVM-published sidecar ports.
|
||||
3. Add a sidecar VM launch spec and lifecycle helpers: pack, create, start,
|
||||
publish/discover ports, stop, delete.
|
||||
4. Switch smolmachines launch from Docker sidecar bundle lifecycle to sidecar VM
|
||||
plus forwarder lifecycle.
|
||||
5. Update egress apply / reload paths for the sidecar VM supervisor.
|
||||
6. Add unit tests for argv shape, URL stamping, teardown, and fail-closed
|
||||
behavior.
|
||||
7. Add or update integration coverage for agent-to-sidecar reachability, host
|
||||
localhost denial, other-bottle alias denial, internal port denial, and
|
||||
teardown cleanup.
|
||||
@@ -3,5 +3,5 @@
|
||||
# These tools are used for code quality checks in CI/CD.
|
||||
|
||||
pylint>=3.0.0
|
||||
pyright>=1.1.411
|
||||
pyright>=1.1.300
|
||||
coverage>=7.0.0
|
||||
|
||||
@@ -81,10 +81,10 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
# those are missing rather than die-ing inside backend.prepare.
|
||||
backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
|
||||
if backend_name == "smolmachines":
|
||||
if sys.platform not in ("darwin", "linux"):
|
||||
if sys.platform != "darwin":
|
||||
raise unittest.SkipTest(
|
||||
f"BOT_BOTTLE_BACKEND=smolmachines is not supported "
|
||||
f"on {sys.platform} (macOS and Linux only)"
|
||||
"BOT_BOTTLE_BACKEND=smolmachines is macOS-only in "
|
||||
"v1 (libkrun TSI)"
|
||||
)
|
||||
if shutil.which("smolvm") is None:
|
||||
raise unittest.SkipTest(
|
||||
|
||||
@@ -21,7 +21,7 @@ security properties the design pivot was about:
|
||||
bind-address mitigation is what closes TSI's port-granularity
|
||||
gap.
|
||||
|
||||
Gated on macOS/Linux + smolvm + docker + not GITEA_ACTIONS — the
|
||||
Gated on macOS + smolvm + docker + not GITEA_ACTIONS — the
|
||||
runner can't host libkrun-backed VMs."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -65,8 +65,8 @@ def _minimal_manifest() -> ManifestIndex:
|
||||
|
||||
@skip_unless_docker()
|
||||
@unittest.skipUnless(
|
||||
platform.system() in ("Darwin", "Linux"),
|
||||
"smolvm requires macOS or Linux",
|
||||
platform.system() == "Darwin",
|
||||
"smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
_smolvm_available(),
|
||||
@@ -141,15 +141,7 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
||||
proxies = [line.strip() for line in r.stdout.splitlines()]
|
||||
self.assertEqual(2, len(proxies), proxies)
|
||||
self.assertEqual(proxies[0], proxies[1], proxies)
|
||||
# macOS: proxy binds to the per-bottle loopback alias (127.x.x.x) so
|
||||
# TSI can intercept guest connections to it. Linux: the guest kernel
|
||||
# routes 127.0.0.0/8 to its own loopback (TSI never sees those), so
|
||||
# the proxy instead binds to the per-bottle bridge gateway (192.168.x.1)
|
||||
# which routes via eth0 and is intercepted by TSI normally.
|
||||
self.assertRegex(
|
||||
proxies[0], r"^http://\d+\.\d+\.\d+\.\d+:\d+$",
|
||||
"expected proxy URL to be an http://IP:port address",
|
||||
)
|
||||
self.assertTrue(proxies[0].startswith("http://127."), proxies[0])
|
||||
|
||||
r = self.bottle.exec(
|
||||
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
exercised against the real binary.
|
||||
|
||||
The full machine-lifecycle round trip (create → start → exec →
|
||||
delete) is gated behind macOS/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()`
|
||||
correctly reports presence and `_smolvm()` can run a no-op
|
||||
subcommand without errors — enough to flag wrapper drift if
|
||||
@@ -23,8 +23,8 @@ from bot_bottle.backend.smolmachines.smolvm import is_available
|
||||
"skipped under act_runner: smolvm not installed on the runner",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
platform.system() in ("Darwin", "Linux"),
|
||||
"smolvm requires macOS or Linux",
|
||||
platform.system() == "Darwin",
|
||||
"smolvm is macOS-only for v1; Linux+KVM path is a future PRD",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
is_available(),
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -9,11 +9,15 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
CLAUDE_HOST_CREDENTIAL_HOSTS,
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
build_agent_provision_plan,
|
||||
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:
|
||||
@@ -35,10 +39,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
)
|
||||
config = Path(tmp, "codex-config.toml").read_text()
|
||||
self.assertEqual("codex", plan.template)
|
||||
self.assertEqual(
|
||||
"/home/node/.codex/packages/standalone/current/bin/codex",
|
||||
plan.command,
|
||||
)
|
||||
self.assertEqual("codex", plan.command)
|
||||
self.assertEqual("read_prompt_file", plan.prompt_mode)
|
||||
self.assertEqual("/tmp/Dockerfile.codex", plan.dockerfile)
|
||||
self.assertEqual(
|
||||
@@ -292,6 +293,67 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
)
|
||||
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):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
|
||||
@@ -12,19 +12,11 @@ from unittest.mock import patch
|
||||
|
||||
import bot_bottle.cli as climod
|
||||
from bot_bottle.cli import main
|
||||
from bot_bottle.db_store import DbStore
|
||||
from bot_bottle.log import Die
|
||||
from bot_bottle.manifest import ManifestError
|
||||
from bot_bottle.store_manager import StoreManager
|
||||
|
||||
|
||||
class TestMainDispatch(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
patcher = patch.object(DbStore, "is_migrated", return_value=True)
|
||||
self._mock_check = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(StoreManager.reset)
|
||||
|
||||
def test_no_args_prints_usage_returns_2(self) -> None:
|
||||
with patch("sys.stderr", io.StringIO()):
|
||||
self.assertEqual(2, main([]))
|
||||
@@ -64,15 +56,6 @@ class TestMainDispatch(unittest.TestCase):
|
||||
main(["x", "a", "b"])
|
||||
self.assertEqual([["a", "b"]], seen)
|
||||
|
||||
def test_missing_env_var_error_maps_to_1(self) -> None:
|
||||
from bot_bottle.errors import MissingEnvVarError
|
||||
|
||||
def boom(_rest: list[str]) -> int:
|
||||
raise MissingEnvVarError("MY_VAR", "MY_VAR is not set")
|
||||
|
||||
with patch.dict(climod.COMMANDS, {"x": boom}), patch("sys.stderr", io.StringIO()):
|
||||
self.assertEqual(1, main(["x"]))
|
||||
|
||||
def test_manifest_error_maps_to_1(self) -> None:
|
||||
def boom(_rest: list[str]) -> int:
|
||||
raise ManifestError("bad manifest")
|
||||
|
||||
@@ -9,7 +9,6 @@ is created.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -184,68 +183,6 @@ class TestCmdStartHeadless(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"])
|
||||
|
||||
def test_cached_images_sets_cached_policy(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "--cached-images", "researcher", "--bottle", "claude",
|
||||
"--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual("cached", self._spec().image_policy)
|
||||
|
||||
def test_cached_images_requires_headless(self):
|
||||
with self.assertRaises(Die):
|
||||
start_mod.cmd_start(["--cached-images", "researcher"])
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestPrepareWithPreflight(unittest.TestCase):
|
||||
"""prepare_with_preflight calls render_preflight with the plan and backend name."""
|
||||
|
||||
def test_render_preflight_called(self):
|
||||
from pathlib import Path
|
||||
mock_backend = MagicMock()
|
||||
mock_plan = MagicMock()
|
||||
mock_backend.prepare.return_value = mock_plan
|
||||
mock_backend.name = "test-backend"
|
||||
render = MagicMock()
|
||||
|
||||
with patch("bot_bottle.cli.start.get_bottle_backend", return_value=mock_backend), \
|
||||
patch("bot_bottle.cli.start._identity_from_plan", return_value="id"), \
|
||||
patch("bot_bottle.cli.start.info"):
|
||||
start_mod.prepare_with_preflight(
|
||||
MagicMock(),
|
||||
stage_dir=Path("/tmp"),
|
||||
render_preflight=render,
|
||||
prompt_yes=MagicMock(),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
render.assert_called_once_with(mock_plan, "test-backend")
|
||||
|
||||
|
||||
class TestNoAgentsDefined(unittest.TestCase):
|
||||
"""cmd_start prints an error and returns 1 when the manifest has no agents."""
|
||||
|
||||
def test_no_agents_defined_returns_1(self):
|
||||
manifest = _make_manifest([], [])
|
||||
with patch(
|
||||
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest
|
||||
), patch("sys.stderr", io.StringIO()) as err:
|
||||
rc = start_mod.cmd_start([])
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("no agents defined", err.getvalue())
|
||||
|
||||
|
||||
class TestTextRenderPreflight(unittest.TestCase):
|
||||
"""_text_render_preflight returns a renderer that prints the backend name."""
|
||||
|
||||
def test_backend_name_in_output(self):
|
||||
render = start_mod._text_render_preflight()
|
||||
plan = MagicMock()
|
||||
with patch("bot_bottle.cli.start._manifest_to_yaml", return_value=""), \
|
||||
patch("sys.stderr", io.StringIO()) as err:
|
||||
render(plan, "my-backend")
|
||||
self.assertIn("my-backend", err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -57,12 +57,6 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
self._bottle_picker_mock = self._bottle_picker_patch.start()
|
||||
self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected
|
||||
|
||||
self._image_policy_patch = patch(
|
||||
"bot_bottle.cli.start._select_image_policy",
|
||||
return_value="fresh",
|
||||
)
|
||||
self._image_policy_patch.start()
|
||||
|
||||
self._env_patch = patch.dict(os.environ, {}, clear=False)
|
||||
self._env_patch.start()
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
@@ -72,7 +66,6 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
self._launch_patch.stop()
|
||||
self._agent_picker_patch.stop()
|
||||
self._bottle_picker_patch.stop()
|
||||
self._image_policy_patch.stop()
|
||||
self._env_patch.stop()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -131,19 +124,6 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
spec = self._launch_mock.call_args[0][0]
|
||||
self.assertEqual(("claude", "dev"), spec.bottle_names)
|
||||
|
||||
def test_image_policy_forwarded_to_spec(self):
|
||||
with patch("bot_bottle.cli.start._select_image_policy", return_value="cached"):
|
||||
start_mod.cmd_start(["researcher"])
|
||||
self._launch_mock.assert_called_once()
|
||||
spec = self._launch_mock.call_args[0][0]
|
||||
self.assertEqual("cached", spec.image_policy)
|
||||
|
||||
def test_image_policy_cancel_returns_0(self):
|
||||
with patch("bot_bottle.cli.start._select_image_policy", return_value=None):
|
||||
rc = start_mod.cmd_start(["researcher"])
|
||||
self.assertEqual(0, rc)
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
def test_empty_bottle_selection_forwarded(self):
|
||||
self._bottle_picker_mock.return_value = []
|
||||
start_mod.cmd_start(["researcher"])
|
||||
@@ -235,7 +215,6 @@ class TestCmdStartLabelCollision(unittest.TestCase):
|
||||
).start()
|
||||
# Stub the bottle picker to always return a selection.
|
||||
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
|
||||
patch("bot_bottle.cli.start._select_image_policy", return_value="fresh").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_no_collision_proceeds_without_reprompt(self):
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Unit: _launch_bottle StaleImageError handling.
|
||||
|
||||
Exercises the while-True / try-except loop around backend.launch:
|
||||
- headless mode → die on stale
|
||||
- interactive mode, user declines → stop without launching
|
||||
- interactive mode, user confirms → retry with skip_stale=True
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.image_cache import StaleImageError
|
||||
from bot_bottle.log import Die
|
||||
|
||||
|
||||
def _fake_plan() -> Any:
|
||||
provision = SimpleNamespace(startup_args=())
|
||||
return cast(Any, SimpleNamespace(
|
||||
agent_provision=provision,
|
||||
agent_provider_template="claude",
|
||||
slug="dev-abc",
|
||||
))
|
||||
|
||||
|
||||
def _stale_cm() -> MagicMock:
|
||||
"""Return a context-manager mock whose __enter__ raises StaleImageError."""
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(side_effect=StaleImageError("image is 5 day(s) old"))
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
def _ok_cm(bottle: Any) -> MagicMock:
|
||||
"""Return a context-manager mock that yields `bottle`."""
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=bottle)
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
class TestLaunchBottleStaleHandling(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.mkdtemp(prefix="cli-stale-test.")
|
||||
|
||||
def _spec(self) -> Any:
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
idx = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
return BottleSpec(
|
||||
manifest=idx,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd=self._tmp,
|
||||
identity="dev-abc",
|
||||
)
|
||||
|
||||
def _run_launch(self, **patch_kwargs: Any) -> int:
|
||||
import bot_bottle.cli.start as start_mod
|
||||
spec = self._spec()
|
||||
with patch.object(start_mod, "prepare_with_preflight",
|
||||
return_value=(_fake_plan(), "dev-abc")), \
|
||||
patch.object(start_mod, "settle_state"), \
|
||||
patch.object(start_mod, "info"):
|
||||
return start_mod._launch_bottle(
|
||||
spec,
|
||||
dry_run=False,
|
||||
backend_name="docker",
|
||||
**patch_kwargs,
|
||||
)
|
||||
|
||||
def test_headless_stale_calls_die(self) -> None:
|
||||
"""In headless mode (assume_yes=True), a StaleImageError must call die()."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.return_value = _stale_cm()
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "die", side_effect=Die()):
|
||||
with self.assertRaises(Die):
|
||||
self._run_launch(assume_yes=True)
|
||||
|
||||
def test_interactive_user_declines_stops_loop(self) -> None:
|
||||
"""Interactive user answering 'n' → loop exits without a second launch."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.return_value = _stale_cm()
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "read_tty_line", return_value="n"), \
|
||||
patch("sys.stderr", new_callable=io.StringIO):
|
||||
rc = self._run_launch(assume_yes=False)
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
# launch was called once (stale), then the user declined → no second call.
|
||||
backend_mock.launch.assert_called_once()
|
||||
|
||||
def test_interactive_user_confirms_retries_with_skip_stale(self) -> None:
|
||||
"""Interactive user answering 'y' → second launch with skip_stale=True."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
bottle_mock = MagicMock()
|
||||
bottle_mock.name = "dev-abc"
|
||||
|
||||
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
||||
if not skip_stale:
|
||||
return _stale_cm()
|
||||
return _ok_cm(bottle_mock)
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.side_effect = launch_side_effect
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "read_tty_line", return_value="y"), \
|
||||
patch.object(start_mod, "attach_agent", return_value=0), \
|
||||
patch.object(start_mod, "capture_claude_session_state"), \
|
||||
patch("sys.stderr", new_callable=io.StringIO):
|
||||
rc = self._run_launch(assume_yes=False)
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
# First call: skip_stale=False → stale. Second call: skip_stale=True → ok.
|
||||
self.assertEqual(2, backend_mock.launch.call_count)
|
||||
calls = backend_mock.launch.call_args_list
|
||||
self.assertFalse(calls[0].kwargs.get("skip_stale", False))
|
||||
self.assertTrue(calls[1].kwargs.get("skip_stale", False))
|
||||
|
||||
def test_interactive_yes_uppercase_also_accepted(self) -> None:
|
||||
"""'Y' or 'YES' should also be accepted as confirmation."""
|
||||
import bot_bottle.cli.start as start_mod
|
||||
|
||||
bottle_mock = MagicMock()
|
||||
bottle_mock.name = "dev-abc"
|
||||
|
||||
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
||||
if not skip_stale:
|
||||
return _stale_cm()
|
||||
return _ok_cm(bottle_mock)
|
||||
|
||||
backend_mock = MagicMock()
|
||||
backend_mock.launch.side_effect = launch_side_effect
|
||||
|
||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||
patch.object(start_mod, "read_tty_line", return_value="YES"), \
|
||||
patch.object(start_mod, "attach_agent", return_value=0), \
|
||||
patch.object(start_mod, "capture_claude_session_state"), \
|
||||
patch("sys.stderr", new_callable=io.StringIO):
|
||||
rc = self._run_launch(assume_yes=False)
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
self.assertEqual(2, backend_mock.launch.call_count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -107,7 +107,7 @@ def _egress_plan(
|
||||
def _supervise_plan() -> SupervisePlan:
|
||||
return SupervisePlan(
|
||||
slug=SLUG,
|
||||
db_path=STATE / "bot-bottle.db",
|
||||
queue_dir=STATE / "supervise" / "queue",
|
||||
internal_network=f"bot-bottle-net-{SLUG}",
|
||||
)
|
||||
|
||||
@@ -118,7 +118,6 @@ def _plan(
|
||||
with_egress: bool = False,
|
||||
supervise: bool = False,
|
||||
canary: bool = False,
|
||||
image_policy: str = "fresh",
|
||||
) -> DockerBottlePlan:
|
||||
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
||||
matrix the renderer's conditional-service logic branches on."""
|
||||
@@ -149,7 +148,6 @@ def _plan(
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
image_policy=image_policy,
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
@@ -267,8 +265,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
||||
def test_agent_depends_only_on_sidecars(self):
|
||||
# Bundle shape: the init supervisor owns intra-bundle daemon
|
||||
# ordering, so the agent waits on the bundle container alone.
|
||||
cases: list[dict[str, Any]] = [{}, {"with_git": True, "with_egress": True, "supervise": True}]
|
||||
for kwargs in cases:
|
||||
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
|
||||
with self.subTest(**kwargs):
|
||||
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
||||
self.assertEqual(["sidecars"], s["depends_on"])
|
||||
@@ -303,11 +300,6 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
||||
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
||||
|
||||
def test_cached_policy_omits_bundle_build(self):
|
||||
sc = self._render(image_policy="cached")["services"]["sidecars"]
|
||||
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
||||
self.assertNotIn("build", sc)
|
||||
|
||||
def test_bundle_container_name_uses_sidecars_prefix(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
||||
@@ -400,7 +392,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
||||
self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings)
|
||||
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
|
||||
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
|
||||
|
||||
def test_volumes_always_includes_egress_ca(self):
|
||||
@@ -416,7 +408,8 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
self.assertIn("/etc/egress", targets)
|
||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
||||
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
||||
self.assertIn("/run/supervise/bot-bottle.db", targets)
|
||||
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
||||
for t in targets))
|
||||
|
||||
def test_extra_hosts_omitted_for_git_upstreams(self):
|
||||
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Unit tests for the host-side configuration store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.config_store import (
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
|
||||
ConfigStore,
|
||||
)
|
||||
from bot_bottle.store_manager import StoreManager
|
||||
|
||||
|
||||
class TestConfigStore(unittest.TestCase):
|
||||
def test_cached_image_warning_days_defaults_to_one(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
|
||||
store = ConfigStore(Path(tmp) / "bot-bottle.db")
|
||||
store.migrate()
|
||||
self.assertEqual(
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
|
||||
store.cached_image_stale_warning_days(),
|
||||
)
|
||||
|
||||
def test_cached_image_warning_days_reads_value(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
|
||||
store = ConfigStore(Path(tmp) / "bot-bottle.db")
|
||||
store.migrate()
|
||||
store.set_cached_image_stale_warning_days(7)
|
||||
self.assertEqual(7, store.cached_image_stale_warning_days())
|
||||
|
||||
def test_config_schema_uses_explicit_settings_columns(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
|
||||
store = ConfigStore(Path(tmp) / "bot-bottle.db")
|
||||
store.migrate()
|
||||
with sqlite3.connect(store.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
columns = [
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(bot_bottle_config)")
|
||||
]
|
||||
self.assertEqual([
|
||||
"id",
|
||||
"cached_image_stale_warning_days",
|
||||
], columns)
|
||||
|
||||
def test_store_manager_includes_config_store(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
|
||||
db = Path(tmp) / "bot-bottle.db"
|
||||
manager = StoreManager(db)
|
||||
self.assertFalse(manager.is_migrated())
|
||||
manager.migrate()
|
||||
self.assertTrue(manager.is_migrated())
|
||||
|
||||
def test_cached_image_warning_days_returns_default_when_db_missing(self) -> None:
|
||||
# When the db file doesn't exist yet (parent exists, file doesn't),
|
||||
# the store returns the default without touching the file.
|
||||
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
|
||||
store = ConfigStore(Path(tmp) / "missing.db")
|
||||
self.assertEqual(
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
|
||||
store.cached_image_stale_warning_days(),
|
||||
)
|
||||
|
||||
def test_cached_image_warning_days_returns_default_on_null_value(self) -> None:
|
||||
# If the row exists but the value is NULL (or not castable to int),
|
||||
# the store falls back to the default.
|
||||
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
|
||||
db_path = Path(tmp) / "bot-bottle.db"
|
||||
store = ConfigStore(db_path)
|
||||
store.migrate()
|
||||
# Write a NULL value directly.
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE bot_bottle_config SET cached_image_stale_warning_days = NULL WHERE id = 1"
|
||||
)
|
||||
self.assertEqual(
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
|
||||
store.cached_image_stale_warning_days(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit: host Claude auth extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.contrib.claude.claude_auth import (
|
||||
claude_auth_path,
|
||||
claude_host_access_token,
|
||||
)
|
||||
from bot_bottle.log import Die
|
||||
|
||||
|
||||
def _cred_json(access_token: str, **extra) -> str: # type: ignore[no-untyped-def]
|
||||
payload: dict = {"claudeAiOauth": {"accessToken": access_token, **extra}}
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
class TestClaudeHostAccessToken(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
|
||||
self.home = Path(self.tmp.name)
|
||||
self.cred_dir = self.home / ".claude"
|
||||
self.cred_dir.mkdir()
|
||||
self.auth_path = self.cred_dir / ".credentials.json"
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
|
||||
self.auth_path.write_text(json.dumps(payload))
|
||||
|
||||
def test_auth_path_uses_home_env(self):
|
||||
self.assertEqual(
|
||||
self.auth_path,
|
||||
claude_auth_path({"HOME": str(self.home)}),
|
||||
)
|
||||
|
||||
# --- file-based (Linux) ---
|
||||
|
||||
def test_file_returns_access_token(self):
|
||||
key = "sk-ant-oat01-real-key"
|
||||
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||
out = claude_host_access_token({"HOME": str(self.home)})
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_file_missing_claude_ai_oauth_dies(self):
|
||||
self._write({"hasCompletedOnboarding": True})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_missing_access_token_dies(self):
|
||||
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_empty_access_token_dies(self):
|
||||
self._write({"claudeAiOauth": {"accessToken": ""}})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_expired_token_dies(self):
|
||||
# expiresAt is milliseconds; 1_000_000 ms is year 1970
|
||||
self._write({
|
||||
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000},
|
||||
})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token(
|
||||
{"HOME": str(self.home)},
|
||||
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
def test_file_future_expiry_is_accepted(self):
|
||||
key = "sk-ant-oat01-y"
|
||||
# 2_000_000_000_000 ms ≈ year 2033
|
||||
self._write({
|
||||
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
|
||||
})
|
||||
out = claude_host_access_token(
|
||||
{"HOME": str(self.home)},
|
||||
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_file_absent_expiry_is_accepted(self):
|
||||
key = "sk-ant-oat01-z"
|
||||
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||
out = claude_host_access_token({"HOME": str(self.home)})
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_file_non_json_dies(self):
|
||||
self.auth_path.write_text("not json {{{")
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_json_array_root_dies(self):
|
||||
self.auth_path.write_text("[]")
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_extra_fields_are_ignored(self):
|
||||
key = "sk-ant-oat01-real"
|
||||
self._write({
|
||||
"claudeAiOauth": {
|
||||
"accessToken": key,
|
||||
"refreshToken": "sk-ant-ort01-secret",
|
||||
"scopes": ["user:inference"],
|
||||
"expiresAt": 2_000_000_000_000,
|
||||
},
|
||||
})
|
||||
out = claude_host_access_token({"HOME": str(self.home)})
|
||||
self.assertEqual(key, out)
|
||||
|
||||
# --- macOS Keychain fallback ---
|
||||
|
||||
def _home_without_creds(self) -> Path:
|
||||
"""A home dir that has .claude/ but no .credentials.json."""
|
||||
empty = self.home / "no-creds"
|
||||
(empty / ".claude").mkdir(parents=True)
|
||||
return empty
|
||||
|
||||
def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.returncode = returncode
|
||||
mock.stdout = stdout
|
||||
return mock
|
||||
|
||||
def test_keychain_used_when_file_absent(self):
|
||||
key = "sk-ant-oat01-keychain"
|
||||
home = self._home_without_creds()
|
||||
with patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||
return_value=self._mock_keychain(_cred_json(key)),
|
||||
), patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||
):
|
||||
out = claude_host_access_token({"HOME": str(home)})
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_keychain_failure_when_file_absent_dies(self):
|
||||
home = self._home_without_creds()
|
||||
with patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||
return_value=self._mock_keychain("", returncode=44),
|
||||
), patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||
):
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(home)})
|
||||
|
||||
def test_no_file_no_keychain_on_linux_dies(self):
|
||||
home = self._home_without_creds()
|
||||
with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"):
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(home)})
|
||||
|
||||
def test_keychain_non_json_dies(self):
|
||||
home = self._home_without_creds()
|
||||
with patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||
return_value=self._mock_keychain("not-json"),
|
||||
), patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||
):
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(home)})
|
||||
|
||||
def test_keychain_security_not_found_dies(self):
|
||||
home = self._home_without_creds()
|
||||
with patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||
side_effect=FileNotFoundError,
|
||||
), patch(
|
||||
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||
):
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(home)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user