fix(supervise): get bot-bottle.db off the data plane (supervise + egress proposals over RPC) #471

Open
didericis-claude wants to merge 38 commits from fix/db-off-data-plane-469 into main
170 changed files with 4842 additions and 3459 deletions
+7 -6
View File
@@ -8,8 +8,8 @@
# this image's mitmproxy / git / gitleaks payload.
#
# Collapses the prior per-daemon images (egress, git-gate,
# supervise) into one. A small stdlib-Python init supervisor at
# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and
# supervise) into one. A small stdlib-Python init supervisor
# (`bot_bottle.gateway.bootstrap`) spawns all daemons, forwards SIGTERM, and
# propagates per-daemon stdout/stderr to the container log with a
# `[name]` prefix. See PRD 0024 for the rationale.
#
@@ -26,8 +26,9 @@
# /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
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
# (No bot-bottle.db mount: the data plane reaches the supervise queue over the
# control-plane RPC and never opens the DB — PRD 0070 / issue #469.)
#
# Exposed ports inside the container:
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
@@ -101,8 +102,8 @@ RUN pip install --no-cache-dir /src/
# WORKDIR here also creates /app so the shim + COPYs below can write into it
# (nothing created /app before this point).
WORKDIR /app
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN printf 'from bot_bottle.gateway.egress.addon import addons\n' > /app/egress_addon.py
COPY bot_bottle/gateway/egress/entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh
# Pre-create runtime directories the compose renderer + start
@@ -122,4 +123,4 @@ EXPOSE 8888 9099 9418 9420 9100
# PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway.bootstrap"]
+74 -819
View File
@@ -1,836 +1,87 @@
"""Per-backend bottle factories.
"""The bottle-backend package: abstract contract + backend selection.
A bottle is a running, isolated environment with claude inside. Each
backend exposes five methods:
Thin by design — nothing framework-heavy is imported at package init, so
importing any `backend.*` submodule (e.g. `backend.docker.util`) doesn't drag
the manifest / egress / git-gate framework into memory. The public names are
re-exported lazily:
prepare(spec, stage_dir=...) -> BottlePlan
Resolves names, validates host-side prerequisites, and writes
scratch files. No remote/runtime resources are created yet.
Safe to call before the y/N preflight.
* the abstract contract (`BottleBackend`, `BottleSpec`, `Bottle`, …) lives in
`backend.base`;
* backend selection / enumeration (`get_bottle_backend`,
`enumerate_active_agents`, …) in `backend.selection`;
* the concrete backends and the freeze helpers in their own submodules.
launch(plan) -> ContextManager[Bottle]
Brings up the container (or VM, or remote machine), provisions
it, yields a Bottle handle, and tears everything down on exit.
prepare_cleanup() -> BottleCleanupPlan
Enumerates orphaned resources left behind by previous bottles
(containers, networks, ...). Idempotent; no side effects.
cleanup(plan) -> None
Actually removes everything described by the cleanup plan.
enumerate_active() -> Sequence[ActiveAgent]
Return every currently-running bottle on this backend, with
enough metadata for callers (CLI `active`, dashboard
agents pane) to render a row.
Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND
(env var). When neither is set, compatible macOS hosts default to
`macos-container`; Linux hosts with KVM default to `firecracker`;
otherwise `docker`. Per PRD 0003 the manifest does not carry a
backend field; the host picks.
`from bot_bottle.backend import X` resolves X on first access via `__getattr__`
and caches it at package level, so existing call-sites (and
`patch.object(backend_mod, X, …)`) keep working.
"""
from __future__ import annotations
import os
import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info, warn
from ..util import read_tty_line
from ..manifest import Manifest, ManifestIndex
from ..supervise import SupervisePlan
from ..util import expand_tilde
from ..env import resolve_env, ResolvedEnv
from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .base import (
ActiveAgent,
Bottle,
BottleBackend,
BottleCleanupPlan,
BottleImages,
BottlePlan,
BottleSpec,
ExecResult,
)
from .selection import (
enumerate_active_agents,
get_bottle_backend,
has_backend,
known_backend_names,
)
from .docker import DockerBottleBackend
from .firecracker import FirecrackerBottleBackend
from .macos_container import MacosContainerBottleBackend
from .freeze import CommitCancelled, Freezer, get_freezer
@dataclass(frozen=True)
class BottleSpec:
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
step consumes it and produces its own backend-specific plan.
Resolved values (image names, container name, scratch paths, runsc
availability) live on the plan, not the spec."""
manifest: ManifestIndex
agent_name: str
copy_cwd: bool
user_cwd: str
# PRD 0016 follow-up: when set, the backend's prepare step uses
# this identity instead of minting a fresh one — the resume path
# (`cli.py resume <identity>`) sets this to continue an existing
# bottle's state. Empty string for a fresh `start`.
identity: str = ""
label: str = ""
color: str = ""
# 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)
class BottlePlan(ABC):
"""Base output of a backend's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
spec: BottleSpec
manifest: Manifest
stage_dir: Path
git_gate_plan: GitGatePlan
@property
def guest_home(self) -> str:
return self.agent_provision.guest_home
@property
def git_gate_insteadof_host(self) -> str:
"""Host (and optional port) used in git-gate insteadOf URLs.
Docker uses the compose-network DNS alias; VM backends may
override with an IP:port when the guest has no DNS."""
return "git-gate"
@property
def git_gate_insteadof_scheme(self) -> str:
"""URL scheme for git-gate insteadOf rewrites. 'git' for
Docker (git daemon); VM backends may override (e.g. 'http'
over a published host port)."""
return "git"
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
@property
def workspace_plan(self) -> WorkspacePlan:
return workspace_plan(self.spec, guest_home=self.guest_home)
def print(self) -> None:
"""Render the y/N preflight summary to stderr."""
spec = self.spec
manifest = self.manifest
agent = manifest.agent
bottle = manifest.bottle
env_names = visible_agent_env_names(
sorted(
set(bottle.env.keys())
| set(self.agent_provision.guest_env.keys())
),
hidden_env_names=self.agent_provision.hidden_env_names,
)
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provision.template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
effective_bottles = (
list(spec.bottle_names) if spec.bottle_names
else ([agent.bottle] if agent.bottle else [])
)
print_multi("bottle ", effective_bottles)
identity = manifest.git_identity_summary()
if identity:
info(f" git identity : {identity}")
git_lines = [
f"{u.name}{u.upstream_host}:{u.upstream_port}"
for u in self.git_gate_plan.upstreams
]
if git_lines:
print_multi(" git gate ", git_lines)
if self.egress_plan.routes:
egress_lines = []
for r in self.egress_plan.routes:
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines)
print(file=sys.stderr)
@dataclass(frozen=True)
class BottleCleanupPlan(ABC):
"""Base output of a backend's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
lists of resources to be removed and implement `print` + `empty`."""
@abstractmethod
def print(self) -> None:
"""Render the cleanup y/N summary to stderr."""
@property
@abstractmethod
def empty(self) -> bool:
"""True iff there is nothing to clean up; the CLI uses this to
short-circuit before showing the y/N."""
@dataclass(frozen=True)
class ExecResult:
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
impl populates it from a `subprocess.CompletedProcess`, but a
VM backend could populate it from any source that produces a
returncode + captured streams."""
returncode: int
stdout: str
stderr: str
@dataclass(frozen=True)
class ActiveAgent:
"""One currently-running agent, as the CLI `active` and
dashboard agents pane render it. ("Agent" is the project's
consistent name for the thing running inside a bottle — the
bottle is the container, the agent is what runs in it.)
Fields are deliberately backend-neutral. `services` is the set
of gateway daemons currently up for this bottle (`egress`,
`git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
list rendering to disambiguate and by the dashboard's
re-attach path."""
backend_name: str
slug: str
agent_name: str # from metadata.json; "?" if missing
started_at: str # ISO 8601 from metadata.json; "" if missing
services: tuple[str, ...] # alphabetical
label: str = ""
color: str = ""
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step.
`exec_agent` runs the selected agent CLI inside the bottle and
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
and returns the captured result. `cp_in` copies a host path into
the bottle. `close` is an idempotent alias for context-manager
teardown.
"""
name: str
@abstractmethod
def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
"""Return the host-side argv that runs the selected agent
inside the bottle. Used by `exec_agent` for foreground
handoffs and by the dashboard's tmux `respawn-pane` flow,
which needs the argv up front (it spawns claude in a tmux
pane rather than as a child of the current process).
Implementations transparently inject
`--append-system-prompt-file` when the bottle was launched
with a provisioned prompt path."""
...
@abstractmethod
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod
def exec(self, script: str, *, user: str = "node") -> ExecResult:
"""Run `script` as a POSIX shell script inside the bottle as
`user` (default `node`, matching the agent image's USER
directive) and return the captured stdout/stderr/returncode.
The bottle's environment (including HTTPS_PROXY pointing at
the egress daemon) is inherited by the child. Non-zero
exit does not raise — callers inspect `returncode`
themselves.
Pass `user="root"` for shell-outs that need privileged file
writes / package install — provisioning calls that need root
bypass `Bottle.exec` and use the backend-specific raw
machine-exec helper, but the tests have a legitimate use
case for arbitrary-user runs."""
@abstractmethod
def cp_in(self, host_path: str, container_path: str) -> None: ...
@abstractmethod
def close(self) -> None: ...
PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
@dataclass(frozen=True)
class BottleImages:
"""Resolved image references (or artifact paths) for a bottle launch.
For Docker/macOS-container backends, `agent` and `sidecar` are string
image refs. For the smolmachines backend they are Path objects pointing
to pre-built `.smolmachine` artifacts."""
agent: str | Path
sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls.
Parameterized over the backend's concrete plan + cleanup-plan types
so subclass methods get the narrow type without isinstance
boilerplate."""
name: str
# Whether this backend can run a container engine *inside* the bottle.
# Backends that cannot must reject `nested_containers: true` rather than
# reach for a host daemon socket (issue #392).
supports_nested_containers: bool = False
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
"""Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created."""
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
reject_nested_containers,
resolve_manifest_dockerfile,
write_launch_metadata,
)
manifest = self._validate(spec)
if not self.supports_nested_containers:
reject_nested_containers(self.name, manifest)
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)
resolved_env = resolve_env(manifest)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
# Manifest may override the Dockerfile per-bottle; otherwise fall
# back to the provider plugin's bundled Dockerfile (next to its
# agent_provider.py module).
if manifest_agent_provider.dockerfile:
agent_dockerfile_path = resolve_manifest_dockerfile(
manifest_agent_provider.dockerfile, spec,
)
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
instance_name=f"bot-bottle-{slug}",
prompt_file=prompt_file,
guest_env=self._build_guest_env(resolved_env),
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
auth_token=manifest_agent_provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace.workdir,
label=spec.label,
color=spec.color,
provider_settings=manifest_agent_provider.settings,
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return {}
def _preflight(self) -> None:
"""
tasks to do before resolving a plan
"""
pass
def _validate(self, spec: BottleSpec) -> Manifest:
"""Cross-backend pre-launch checks. Parses the selected agent and
its bottle (raising ManifestError on invalid content), confirms
skills are present on the host, and every git IdentityFile resolves.
Returns the loaded Manifest for the selected agent. Subclasses with
additional preconditions should override and call
`super()._validate(spec)` first."""
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
self._validate_skills(manifest.agent.skills)
self._validate_agent_provider_dockerfile(spec, manifest)
return manifest
def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's
`~/.claude/skills/`. The check is purely host-side, so the
default impl covers every backend."""
for name in skills:
path = host_skill_dir(name)
if not os.path.isdir(path):
die(
f"skill '{name}' not found on host at {path}. "
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
bottle = manifest.bottle
dockerfile = bottle.agent_provider.dockerfile
if not dockerfile:
return
path = Path(expand_tilde(dockerfile))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
if not path.is_file():
effective = (
", ".join(spec.bottle_names) if spec.bottle_names else manifest.agent.bottle
)
die(
f"agent_provider.dockerfile for bottle "
f"'{effective}' not found: {path}"
)
@abstractmethod
def _resolve_plan(self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds. Instance name, image,
prompt file, Dockerfile path, and guest home all live on
`agent_provision_plan` — the source of truth."""
def prelaunch_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. Called by the CLI before
launch so the operator can be prompted outside the launch context."""
@contextmanager
def launch(self, plan: PlanT) -> Generator[Bottle, None, None]:
"""Template: build or load images, then delegate to _launch_impl."""
images = self._build_or_load_images(plan)
with self._launch_impl(plan, images) as bottle:
yield bottle
@abstractmethod
def _build_or_load_images(self, plan: PlanT) -> BottleImages:
"""Return the agent and sidecar image references (or artifact paths)
for this plan, building fresh images when the policy requires it."""
@abstractmethod
def _launch_impl(self, plan: PlanT, images: BottleImages) -> AbstractContextManager[Bottle]:
"""Bring up the bottle using pre-resolved images; yield a handle; tear down on exit."""
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
"""Copy host-side files (CA cert, prompt, skills, .git) into
the running bottle. Called from `launch` after the container
/ machine is up. Returns the in-container prompt path if a
prompt was provisioned, else None — the Bottle handle uses it
to decide whether to add provider-specific prompt args to the
agent's argv.
Default orchestration: ca → prompt → provider apply → skills
→ workspace → git → supervise-mcp. CA install runs first so
the agent's trust store is rebuilt before anything inside the
agent makes a TLS call.
Per PRD 0050 the per-provider steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration)
live on the `AgentProvider` plugin. The backend only owns the
steps that are about backend infrastructure (CA, workspace,
git) and surfaces the supervise daemon URL its launch step
knows about via `supervise_mcp_url`.
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
on the agent's HTTP_PROXY path so every tool that respects
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
intercepted without per-tool reconfiguration."""
provider = get_provider(plan.agent_provision.template)
provider.provision_ca(bottle, plan)
prompt_path = provider.provision_prompt(plan, bottle)
provider.provision(plan, bottle)
provider.provision_skills(plan, bottle)
self.provision_workspace(plan, bottle)
provider.provision_git(bottle, plan)
provider.provision_supervise_mcp(
plan, bottle, self.supervise_mcp_url(plan),
)
return prompt_path
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle.
This is the only supported workspace-provisioning path: Docker
does not build a derived image containing the current
workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path = shlex.quote(workspace.guest_path)
guest_parent = shlex.quote(guest_parent)
owner = shlex.quote(workspace.owner)
mode = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
user="root",
)
def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise
gateway, or "" when this bottle has no gateway. The provider
plugin's `provision_supervise_mcp` uses it to register the
MCP entry inside the guest.
Default returns "" so backends without supervise support
don't have to implement it. Docker and firecracker override."""
del plan
return ""
def ensure_orchestrator(self) -> str:
"""Bring up this backend's per-host orchestrator + shared gateway
(idempotent) and return the host-reachable control-plane URL.
This is the backend-agnostic bring-up entry point: `launch` calls
it as part of starting a bottle, and operator tools (`supervise`)
call it to start the control plane on demand when none is running
yet. Docker starts the orchestrator + gateway containers;
firecracker boots the infra VM. Backends with no orchestrator
(macos-container) die with a pointer — the default here."""
die(f"backend {self.name!r} has no orchestrator control plane")
@abstractmethod
def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side
effects; safe to call before the y/N."""
@abstractmethod
def cleanup(self, plan: CleanupT) -> None:
"""Remove everything described by the cleanup plan."""
@abstractmethod
def enumerate_active(self) -> Sequence[ActiveAgent]:
"""Return every currently-running agent on this backend.
Empty when none. Backend-specific: docker queries `docker
compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata."""
@classmethod
@abstractmethod
def is_available(cls) -> bool:
"""Whether this backend's runtime prerequisites are satisfied
on the current host. Docker → `docker` on PATH; firecracker →
Linux + KVM. Used by the cross-backend
`enumerate_active_agents` / `cmd_cleanup` to skip backends
the operator hasn't installed, so a docker-only host
doesn't fail when `cli.py active` walks past
firecracker."""
@classmethod
@abstractmethod
def setup(cls) -> int:
"""Emit this backend's one-time host setup — privileged network
pool, daemon bring-up, install pointers, etc. — as
host-appropriate config or commands. Prints to stdout/stderr and
returns a shell exit code (0 = nothing to report / success). A
backend that needs no host setup prints a short note and returns
0. Invoked generically by `./cli.py backend setup [--backend=…]`
so operators can provision any backend without a
backend-specific command. Classmethod (like `is_available`) —
it's a host query, not per-bottle state."""
@classmethod
@abstractmethod
def status(cls) -> int:
"""Report whether this backend's prerequisites are satisfied on
the host — binaries, daemon reachability, network pool, range
conflicts, etc. Prints a human-readable summary; returns 0 when
the backend is ready to launch and non-zero when something is
missing. Invoked by `./cli.py backend status [--backend=…]`."""
@classmethod
@abstractmethod
def teardown(cls) -> int:
"""Undo `setup()` — the inverse operation, surfaced as
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
with setup: where setup is advisory (prints the privileged
commands / declarative config to apply), teardown prints the
commands / config change to remove the host prerequisites. A
backend with no host setup prints a short note and returns 0.
Not called by the launch path or the test suite."""
# _backends is None until the first call to _get_backends(), at which
# point all three concrete backend classes are imported and instantiated.
# Keeping the imports out of module scope means that importing any
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
# firecracker and macos-container implementations into memory.
#
# Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it
# is not None, so test fakes take effect without triggering real imports.
_backends: dict[str, BottleBackend[Any, Any]] | None = None
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
"""Return the registry of all backend instances, loading lazily on first call."""
global _backends # pylint: disable=global-statement
if _backends is None:
from .docker import DockerBottleBackend
from .firecracker import FirecrackerBottleBackend
from .macos_container import MacosContainerBottleBackend
_backends = {
"docker": DockerBottleBackend(),
"firecracker": FirecrackerBottleBackend(),
"macos-container": MacosContainerBottleBackend(),
}
return _backends
# Public name -> submodule that defines it. Contract types resolve from `base`,
# selection/enumeration from `selection`, the concrete backends + freeze helpers
# from their own subpackages.
_LAZY_MODULES: dict[str, str] = {
"BottleSpec": "base",
"BottlePlan": "base",
"BottleCleanupPlan": "base",
"ExecResult": "base",
"ActiveAgent": "base",
"Bottle": "base",
"BottleImages": "base",
"BottleBackend": "base",
"get_bottle_backend": "selection",
"known_backend_names": "selection",
"has_backend": "selection",
"enumerate_active_agents": "selection",
"_print_vm_install_instructions": "selection",
"DockerBottleBackend": "docker",
"FirecrackerBottleBackend": "firecracker",
"MacosContainerBottleBackend": "macos_container",
"CommitCancelled": "freeze",
"Freezer": "freeze",
"get_freezer": "freeze",
}
def __getattr__(name: str) -> Any:
"""Lazily surface concrete backend classes and freeze symbols at the
package level so existing `from bot_bottle.backend import X` and
`patch.object(backend_mod, X, ...)` call-sites keep working without
forcing an import of every backend at module-init time."""
if name == "DockerBottleBackend":
from .docker import DockerBottleBackend
globals()[name] = DockerBottleBackend
return DockerBottleBackend
if name == "FirecrackerBottleBackend":
from .firecracker import FirecrackerBottleBackend
globals()[name] = FirecrackerBottleBackend
return FirecrackerBottleBackend
if name == "MacosContainerBottleBackend":
from .macos_container import MacosContainerBottleBackend
globals()[name] = MacosContainerBottleBackend
return MacosContainerBottleBackend
if name == "CommitCancelled":
from .freeze import CommitCancelled
globals()[name] = CommitCancelled
return CommitCancelled
if name == "Freezer":
from .freeze import Freezer
globals()[name] = Freezer
return Freezer
if name == "get_freezer":
from .freeze import get_freezer
globals()[name] = get_freezer
return get_freezer
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
"""Lazily surface the package's public names from their submodules and
cache them at package level so `from bot_bottle.backend import X` and
`patch.object(backend_mod, X, …)` keep working without importing the
framework (or every backend) at package-init time."""
mod = _LAZY_MODULES.get(name)
if mod is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
def get_bottle_backend(
name: str | None = None,
*,
prompt: bool = True,
) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend.
`name` precedence:
1. explicit arg (e.g. resume passes the recorded backend name)
2. BOT_BOTTLE_BACKEND env var
3. auto-selection: VM backend first, docker fallback with prompt
`prompt` controls whether auto-selection may block on an interactive
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
non-interactive contexts (headless launches, CI) so the call dies
with an actionable message instead of hanging.
Dies with a pointer at the known backends if the chosen name
isn't implemented."""
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
if resolved is None:
resolved = _auto_select_backend(prompt=prompt)
backends = _get_backends()
if resolved not in backends:
known = ", ".join(sorted(backends))
die(f"unknown backend {resolved!r}; known backends: {known}")
return backends[resolved]
def _platform_vm_suggestion() -> str:
"""Platform-appropriate VM backend name for install suggestions."""
return "macos-container" if sys.platform == "darwin" else "firecracker"
def _print_vm_install_instructions() -> None:
"""Print platform-appropriate VM backend install instructions to stderr."""
vm = _platform_vm_suggestion()
if vm == "macos-container":
info("Install Apple Container: https://github.com/apple/container/releases")
info("Then start the service: container system start")
else:
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
info("Configure the host: ./cli.py backend setup")
def _auto_select_backend(prompt: bool = True) -> str:
"""Tier-1 / tier-2 backend auto-selection.
Tier 1: VM backend — macos-container on macOS when Apple Container is
installed; firecracker on KVM-capable Linux even before the binary is
present (its preflight prints an install pointer).
Tier 2: docker, with a security warning and an interactive prompt.
When `prompt=False` (headless / CI), dies with an actionable message
instead of blocking on a TTY read. When docker is also absent, prints
VM install instructions and exits.
"""
# --- Tier 1: VM backend -----------------------------------------
if has_backend("macos-container"):
return "macos-container"
# A KVM-capable Linux host defaults to firecracker even when the
# `firecracker` binary isn't installed yet: selecting it here routes
# start through firecracker's preflight, which prints an install
# pointer, instead of silently falling back to docker.
from .firecracker import FirecrackerBottleBackend
if FirecrackerBottleBackend.is_host_capable():
return "firecracker"
# --- Tier 2: docker fallback ------------------------------------
if not has_backend("docker"):
info("No backend available on this host.")
_print_vm_install_instructions()
die("no backend available; install a VM backend and re-run")
vm = _platform_vm_suggestion()
warn(
"docker is less secure than VM backends — "
"containers share the host kernel."
)
if not prompt:
die(
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
f"with docker, or install the {vm!r} backend."
)
sys.stderr.write(
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
f" [i] show {vm} install instructions and exit\n"
" [d] use docker anyway\n"
" [q] quit\n"
"bot-bottle: choice [i/d/q]: "
)
sys.stderr.flush()
reply = read_tty_line().strip().lower()
if reply == "d":
return "docker"
if reply == "i":
_print_vm_install_instructions()
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
argparse (`--backend` choices) and the dashboard's backend
picker."""
return tuple(sorted(_get_backends()))
def has_backend(name: str) -> bool:
"""Whether the named backend's runtime prerequisites are
available on the current host. Cross-backend callers (list,
cleanup) skip unavailable backends so a docker-only host
doesn't fail when the firecracker backend isn't usable,
and vice versa.
Returns False for unknown names so callers can pass
arbitrary input without separate validation."""
backends = _get_backends()
if name not in backends:
return False
return backends[name].is_available()
def enumerate_active_agents() -> list[ActiveAgent]:
"""All currently-running agents, across every available
backend. Used by CLI `active` and the dashboard's agents
pane so neither has to know which backends exist. Skips
backends whose `is_available()` reports False.
Sorted by `(started_at, slug)` so the list is stable across
dashboard refresh ticks — agents don't shift position while
the operator navigates with arrow keys. ISO 8601 timestamps
sort lexicographically in chronological order; `slug` is the
deterministic tiebreaker. Agents with missing metadata
(`started_at == ""`) sort first."""
out: list[ActiveAgent] = []
backends = _get_backends()
for name in sorted(backends):
if not backends[name].is_available():
continue
out.extend(backends[name].enumerate_active())
out.sort(key=lambda a: (a.started_at, a.slug))
return out
value = getattr(import_module(f"{__name__}.{mod}"), name)
globals()[name] = value
return value
__all__ = [
@@ -838,14 +89,18 @@ __all__ = [
"Bottle",
"BottleBackend",
"BottleCleanupPlan",
"BottleImages",
"BottlePlan",
"BottleSpec",
"CommitCancelled",
"ExecResult",
"CommitCancelled",
"Freezer",
"get_freezer",
"DockerBottleBackend",
"FirecrackerBottleBackend",
"MacosContainerBottleBackend",
"enumerate_active_agents",
"get_bottle_backend",
"get_freezer",
"has_backend",
"known_backend_names",
]
+607
View File
@@ -0,0 +1,607 @@
"""The abstract backend contract (PRD 0018 / 0070).
The backend-neutral types every bottle backend implements: the launch
`BottleSpec`, the `BottlePlan` / `BottleCleanupPlan` ABCs, the running-`Bottle`
+ `ExecResult` shapes, `BottleImages`, and the `BottleBackend` ABC itself.
This carries the framework imports (manifest, egress, git-gate, env, workspace,
agent-provider) the contract's signatures and helpers need — which is why it
lives here rather than in `backend/__init__.py`: touching an unrelated
`backend.*` module then doesn't drag the whole framework into memory. The
thin package `__init__` re-exports these names lazily.
"""
from __future__ import annotations
import os
import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
from ..util import expand_tilde
from ..manifest import Manifest, ManifestIndex
from ..supervisor.plan import SupervisePlan
from ..env import resolve_env, ResolvedEnv
from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
@dataclass(frozen=True)
class BottleSpec:
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
step consumes it and produces its own backend-specific plan.
Resolved values (image names, container name, scratch paths, runsc
availability) live on the plan, not the spec."""
manifest: ManifestIndex
agent_name: str
copy_cwd: bool
user_cwd: str
# PRD 0016 follow-up: when set, the backend's prepare step uses
# this identity instead of minting a fresh one — the resume path
# (`cli.py resume <identity>`) sets this to continue an existing
# bottle's state. Empty string for a fresh `start`.
identity: str = ""
label: str = ""
color: str = ""
# 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)
class BottlePlan(ABC):
"""Base output of a backend's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
spec: BottleSpec
manifest: Manifest
stage_dir: Path
git_gate_plan: GitGatePlan
@property
def guest_home(self) -> str:
return self.agent_provision.guest_home
@property
def git_gate_insteadof_host(self) -> str:
"""Host (and optional port) used in git-gate insteadOf URLs.
Docker uses the compose-network DNS alias; VM backends may
override with an IP:port when the guest has no DNS."""
return "git-gate"
@property
def git_gate_insteadof_scheme(self) -> str:
"""URL scheme for git-gate insteadOf rewrites. 'git' for
Docker (git daemon); VM backends may override (e.g. 'http'
over a published host port)."""
return "git"
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
@property
def workspace_plan(self) -> WorkspacePlan:
return workspace_plan(self.spec, guest_home=self.guest_home)
def print(self) -> None:
"""Render the y/N preflight summary to stderr."""
spec = self.spec
manifest = self.manifest
agent = manifest.agent
bottle = manifest.bottle
env_names = visible_agent_env_names(
sorted(
set(bottle.env.keys())
| set(self.agent_provision.guest_env.keys())
),
hidden_env_names=self.agent_provision.hidden_env_names,
)
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provision.template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
effective_bottles = (
list(spec.bottle_names) if spec.bottle_names
else ([agent.bottle] if agent.bottle else [])
)
print_multi("bottle ", effective_bottles)
identity = manifest.git_identity_summary()
if identity:
info(f" git identity : {identity}")
git_lines = [
f"{u.name}{u.upstream_host}:{u.upstream_port}"
for u in self.git_gate_plan.upstreams
]
if git_lines:
print_multi(" git gate ", git_lines)
if self.egress_plan.routes:
egress_lines = []
for r in self.egress_plan.routes:
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines)
print(file=sys.stderr)
@dataclass(frozen=True)
class BottleCleanupPlan(ABC):
"""Base output of a backend's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
lists of resources to be removed and implement `print` + `empty`."""
@abstractmethod
def print(self) -> None:
"""Render the cleanup y/N summary to stderr."""
@property
@abstractmethod
def empty(self) -> bool:
"""True iff there is nothing to clean up; the CLI uses this to
short-circuit before showing the y/N."""
@dataclass(frozen=True)
class ExecResult:
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
impl populates it from a `subprocess.CompletedProcess`, but a
VM backend could populate it from any source that produces a
returncode + captured streams."""
returncode: int
stdout: str
stderr: str
@dataclass(frozen=True)
class ActiveAgent:
"""One currently-running agent, as the CLI `active` and
dashboard agents pane render it. ("Agent" is the project's
consistent name for the thing running inside a bottle — the
bottle is the container, the agent is what runs in it.)
Fields are deliberately backend-neutral. `services` is the set
of gateway daemons currently up for this bottle (`egress`,
`git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
list rendering to disambiguate and by the dashboard's
re-attach path."""
backend_name: str
slug: str
agent_name: str # from metadata.json; "?" if missing
started_at: str # ISO 8601 from metadata.json; "" if missing
services: tuple[str, ...] # alphabetical
label: str = ""
color: str = ""
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step.
`exec_agent` runs the selected agent CLI inside the bottle and
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
and returns the captured result. `cp_in` copies a host path into
the bottle. `close` is an idempotent alias for context-manager
teardown.
"""
name: str
@abstractmethod
def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
"""Return the host-side argv that runs the selected agent
inside the bottle. Used by `exec_agent` for foreground
handoffs and by the dashboard's tmux `respawn-pane` flow,
which needs the argv up front (it spawns claude in a tmux
pane rather than as a child of the current process).
Implementations transparently inject
`--append-system-prompt-file` when the bottle was launched
with a provisioned prompt path."""
...
@abstractmethod
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod
def exec(self, script: str, *, user: str = "node") -> ExecResult:
"""Run `script` as a POSIX shell script inside the bottle as
`user` (default `node`, matching the agent image's USER
directive) and return the captured stdout/stderr/returncode.
The bottle's environment (including HTTPS_PROXY pointing at
the egress daemon) is inherited by the child. Non-zero
exit does not raise — callers inspect `returncode`
themselves.
Pass `user="root"` for shell-outs that need privileged file
writes / package install — provisioning calls that need root
bypass `Bottle.exec` and use the backend-specific raw
machine-exec helper, but the tests have a legitimate use
case for arbitrary-user runs."""
@abstractmethod
def cp_in(self, host_path: str, container_path: str) -> None: ...
@abstractmethod
def close(self) -> None: ...
PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
@dataclass(frozen=True)
class BottleImages:
"""Resolved image references (or artifact paths) for a bottle launch.
For Docker/macOS-container backends, `agent` and `sidecar` are string
image refs. For the smolmachines backend they are Path objects pointing
to pre-built `.smolmachine` artifacts."""
agent: str | Path
sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls.
Parameterized over the backend's concrete plan + cleanup-plan types
so subclass methods get the narrow type without isinstance
boilerplate."""
name: str
# Whether this backend can run a container engine *inside* the bottle.
# Backends that cannot must reject `nested_containers: true` rather than
# reach for a host daemon socket (issue #392).
supports_nested_containers: bool = False
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
"""Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created."""
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
reject_nested_containers,
resolve_manifest_dockerfile,
write_launch_metadata,
)
manifest = self._validate(spec)
if not self.supports_nested_containers:
reject_nested_containers(self.name, manifest)
self._preflight()
from ..git_gate import GitGate
manifest = GitGate().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)
resolved_env = resolve_env(manifest)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
# Manifest may override the Dockerfile per-bottle; otherwise fall
# back to the provider plugin's bundled Dockerfile (next to its
# agent_provider.py module).
if manifest_agent_provider.dockerfile:
agent_dockerfile_path = resolve_manifest_dockerfile(
manifest_agent_provider.dockerfile, spec,
)
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
instance_name=f"bot-bottle-{slug}",
prompt_file=prompt_file,
guest_env=self._build_guest_env(resolved_env),
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
auth_token=manifest_agent_provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace.workdir,
label=spec.label,
color=spec.color,
provider_settings=manifest_agent_provider.settings,
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return {}
def _preflight(self) -> None:
"""
tasks to do before resolving a plan
"""
pass
def _validate(self, spec: BottleSpec) -> Manifest:
"""Cross-backend pre-launch checks. Parses the selected agent and
its bottle (raising ManifestError on invalid content), confirms
skills are present on the host, and every git IdentityFile resolves.
Returns the loaded Manifest for the selected agent. Subclasses with
additional preconditions should override and call
`super()._validate(spec)` first."""
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
self._validate_skills(manifest.agent.skills)
self._validate_agent_provider_dockerfile(spec, manifest)
return manifest
def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's
`~/.claude/skills/`. The check is purely host-side, so the
default impl covers every backend."""
for name in skills:
path = host_skill_dir(name)
if not os.path.isdir(path):
die(
f"skill '{name}' not found on host at {path}. "
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
bottle = manifest.bottle
dockerfile = bottle.agent_provider.dockerfile
if not dockerfile:
return
path = Path(expand_tilde(dockerfile))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
if not path.is_file():
effective = (
", ".join(spec.bottle_names) if spec.bottle_names else manifest.agent.bottle
)
die(
f"agent_provider.dockerfile for bottle "
f"'{effective}' not found: {path}"
)
@abstractmethod
def _resolve_plan(self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds. Instance name, image,
prompt file, Dockerfile path, and guest home all live on
`agent_provision_plan` — the source of truth."""
def prelaunch_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. Called by the CLI before
launch so the operator can be prompted outside the launch context."""
@contextmanager
def launch(self, plan: PlanT) -> Generator[Bottle, None, None]:
"""Template: build or load images, then delegate to _launch_impl."""
images = self._build_or_load_images(plan)
with self._launch_impl(plan, images) as bottle:
yield bottle
@abstractmethod
def _build_or_load_images(self, plan: PlanT) -> BottleImages:
"""Return the agent and sidecar image references (or artifact paths)
for this plan, building fresh images when the policy requires it."""
@abstractmethod
def _launch_impl(self, plan: PlanT, images: BottleImages) -> AbstractContextManager[Bottle]:
"""Bring up the bottle using pre-resolved images; yield a handle; tear down on exit."""
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
"""Copy host-side files (CA cert, prompt, skills, .git) into
the running bottle. Called from `launch` after the container
/ machine is up. Returns the in-container prompt path if a
prompt was provisioned, else None — the Bottle handle uses it
to decide whether to add provider-specific prompt args to the
agent's argv.
Default orchestration: ca → prompt → provider apply → skills
→ workspace → git → supervise-mcp. CA install runs first so
the agent's trust store is rebuilt before anything inside the
agent makes a TLS call.
Per PRD 0050 the per-provider steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration)
live on the `AgentProvider` plugin. The backend only owns the
steps that are about backend infrastructure (CA, workspace,
git) and surfaces the supervise daemon URL its launch step
knows about via `supervise_mcp_url`.
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
on the agent's HTTP_PROXY path so every tool that respects
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
intercepted without per-tool reconfiguration."""
provider = get_provider(plan.agent_provision.template)
provider.provision_ca(bottle, plan)
prompt_path = provider.provision_prompt(plan, bottle)
provider.provision(plan, bottle)
provider.provision_skills(plan, bottle)
self.provision_workspace(plan, bottle)
provider.provision_git(bottle, plan)
provider.provision_supervise_mcp(
plan, bottle, self.supervise_mcp_url(plan),
)
return prompt_path
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle.
This is the only supported workspace-provisioning path: Docker
does not build a derived image containing the current
workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path = shlex.quote(workspace.guest_path)
guest_parent = shlex.quote(guest_parent)
owner = shlex.quote(workspace.owner)
mode = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
user="root",
)
def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise
gateway, or "" when this bottle has no gateway. The provider
plugin's `provision_supervise_mcp` uses it to register the
MCP entry inside the guest.
Default returns "" so backends without supervise support
don't have to implement it. Docker and firecracker override."""
del plan
return ""
def ensure_orchestrator(self) -> str:
"""Bring up this backend's per-host orchestrator + shared gateway
(idempotent) and return the host-reachable control-plane URL.
This is the backend-agnostic bring-up entry point: `launch` calls
it as part of starting a bottle, and operator tools (`supervise`)
call it to start the control plane on demand when none is running
yet. Docker starts the orchestrator + gateway containers;
firecracker boots the infra VM. Backends with no orchestrator
(macos-container) die with a pointer — the default here."""
die(f"backend {self.name!r} has no orchestrator control plane")
@abstractmethod
def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side
effects; safe to call before the y/N."""
@abstractmethod
def cleanup(self, plan: CleanupT) -> None:
"""Remove everything described by the cleanup plan."""
@abstractmethod
def enumerate_active(self) -> Sequence[ActiveAgent]:
"""Return every currently-running agent on this backend.
Empty when none. Backend-specific: docker queries `docker
compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata."""
@classmethod
@abstractmethod
def is_available(cls) -> bool:
"""Whether this backend's runtime prerequisites are satisfied
on the current host. Docker → `docker` on PATH; firecracker →
Linux + KVM. Used by the cross-backend
`enumerate_active_agents` / `cmd_cleanup` to skip backends
the operator hasn't installed, so a docker-only host
doesn't fail when `cli.py active` walks past
firecracker."""
@classmethod
@abstractmethod
def setup(cls) -> int:
"""Emit this backend's one-time host setup — privileged network
pool, daemon bring-up, install pointers, etc. — as
host-appropriate config or commands. Prints to stdout/stderr and
returns a shell exit code (0 = nothing to report / success). A
backend that needs no host setup prints a short note and returns
0. Invoked generically by `./cli.py backend setup [--backend=…]`
so operators can provision any backend without a
backend-specific command. Classmethod (like `is_available`) —
it's a host query, not per-bottle state."""
@classmethod
@abstractmethod
def status(cls) -> int:
"""Report whether this backend's prerequisites are satisfied on
the host — binaries, daemon reachability, network pool, range
conflicts, etc. Prints a human-readable summary; returns 0 when
the backend is ready to launch and non-zero when something is
missing. Invoked by `./cli.py backend status [--backend=…]`."""
@classmethod
@abstractmethod
def teardown(cls) -> int:
"""Undo `setup()` — the inverse operation, surfaced as
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
with setup: where setup is advisory (prints the privileged
commands / declarative config to apply), teardown prints the
commands / config change to remove the host prerequisites. A
backend with no host setup prints a short note and returns 0.
Not called by the launch path or the test suite."""
+2 -2
View File
@@ -13,7 +13,7 @@ from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..orchestrator.client import OrchestratorClient, RegisteredBottle
from ..orchestrator.registration import registration_inputs
from ..orchestrator.secret_store import new_env_var_secret
from ..orchestrator.store.secret_store import new_env_var_secret
from .docker.gateway_provision import GatewayTransport, deprovision_git_gate, provision_git_gate
@@ -58,7 +58,7 @@ def teardown_consolidated(
) -> None:
"""Deregister the bottle and remove its git-gate state. Both steps are
idempotent so this is safe from a cleanup trap."""
from ..orchestrator.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS
from ..orchestrator.store.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS
OrchestratorClient(
orchestrator_url,
timeout=timeout if timeout is not None else DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
+31 -7
View File
@@ -11,18 +11,42 @@ The bulk of the implementation lives in sibling modules:
- launch: bring-up + teardown context manager
- cleanup: orphan enumeration, removal, active listing
- backend: DockerBottleBackend façade wiring the above
- infra: DockerInfraService (the per-host infra container)
This file only re-exports the public names so
`from bot_bottle.backend.docker import DockerBottleBackend` keeps
working.
Thin by design: the public names are re-exported lazily via `__getattr__`, so
importing a leaf like `backend.docker.util` doesn't drag `DockerBottleBackend`
(and the whole framework it pulls) into memory.
"""
from __future__ import annotations
from .backend import DockerBottleBackend
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .backend import DockerBottleBackend
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
_LAZY_MODULES: dict[str, str] = {
"DockerBottleBackend": "backend",
"DockerBottle": "bottle",
"DockerBottleCleanupPlan": "bottle_cleanup_plan",
"DockerBottlePlan": "bottle_plan",
}
def __getattr__(name: str) -> Any:
mod = _LAZY_MODULES.get(name)
if mod is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
value = getattr(import_module(f"{__name__}.{mod}"), name)
globals()[name] = value
return value
__all__ = [
"DockerBottle",
+4 -4
View File
@@ -24,12 +24,12 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ...supervisor.types import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from . import cleanup as _cleanup
@@ -112,8 +112,8 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
yield bottle
def ensure_orchestrator(self) -> str:
from ...orchestrator.lifecycle import OrchestratorService
return OrchestratorService().ensure_running()
from .infra import DockerInfraService
return DockerInfraService().ensure_running()
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
"""Docker bottles reach the supervise daemon via the
@@ -16,8 +16,8 @@ from __future__ import annotations
from typing import Any
from ...egress import egress_agent_env_entries
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...egress import Egress
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .bottle_plan import DockerBottlePlan
from .egress import EGRESS_PORT
@@ -63,7 +63,7 @@ def consolidated_agent_compose(
# env (set in launch.py) and is never written to the compose file on disk.
if getattr(plan, "env_var_secret", ""):
env.append(ENV_VAR_SECRET_NAME)
env.extend(egress_agent_env_entries(plan.egress_plan))
env.extend(Egress().agent_env_entries(plan.egress_plan))
service: dict[str, Any] = {
"image": plan.image,
@@ -16,13 +16,13 @@ from __future__ import annotations
from dataclasses import dataclass
from ... import log
from ...docker_cmd import run_docker
from .util import run_docker
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...orchestrator.client import OrchestratorClient
from ...orchestrator.gateway import GATEWAY_NETWORK
from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...gateway import GATEWAY_NETWORK
from .infra import INFRA_NAME, DockerInfraService
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.reprovision import reprovision_bottles
from ..consolidated_util import provision_bottle
from ..consolidated_util import teardown_consolidated as _teardown_util
@@ -144,7 +144,7 @@ def launch_consolidated(
*,
image_ref: str = "",
tokens: dict[str, str] | None = None,
service: OrchestratorService | None = None,
service: DockerInfraService | None = None,
infra_name: str = INFRA_NAME,
network: str = GATEWAY_NETWORK,
) -> LaunchContext:
@@ -154,7 +154,7 @@ def launch_consolidated(
Also reprovisiones egress tokens for any already-running bottles that lost
their in-memory credentials (e.g. after an infra container restart), so
they regain egress access before the new bottle is registered."""
service = service or OrchestratorService()
service = service or DockerInfraService()
url = service.ensure_running()
_reprovision_running_bottles(url, network=network, infra_name=infra_name)
client = OrchestratorClient(url)
@@ -1,136 +1,21 @@
"""The consolidated per-host gateway (PRD 0070).
The core consolidation win: **one** persistent gateway per host, shared by
every bottle, instead of a gateway per bottle. It's safe to share
because the attribution invariant (source IP + identity token, see
`registry`) lets the gateway attribute each request to the right bottle
so per-bottle policy lives in one long-lived process keyed on who's calling.
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `DockerGateway`
is the docker implementation; a firecracker gateway VM slots in later.
The defining behaviour is **idempotent singleton**: `ensure_running` starts
the instance if absent and is a no-op if it's already up, so N bottle
launches never spawn N gateways.
"""
from __future__ import annotations
import abc
import os
import time
from pathlib import Path
from ..docker_cmd import run_docker
from ..paths import (
CONTROL_PLANE_TOKEN_ENV,
host_control_plane_token,
host_db_path,
from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker
from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV,
host_orchestrator_token,
host_gateway_ca_dir,
)
from ..supervise import DB_PATH_IN_CONTAINER
# The host DB dir is bind-mounted here so the gateway's supervise daemon
# writes its queued proposals into the ONE host DB (the same file the
# orchestrator container opens and the operator reaches over HTTP).
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
# The gateway's mitmproxy writes its CA a beat after the container starts, so
# reads poll for it rather than assuming it's there on a fresh launch.
_CA_POLL_SECONDS = 0.5
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
GATEWAY_NAME = "bot-bottle-orch-gateway"
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# The single user-defined network the gateway and every agent bottle share.
# Agents attach here with a pinned IP and reach the gateway's egress /
# git-http / supervise ports by its address — no host port publishing, and
# the source IP the gateway attributes by is the address on this network.
GATEWAY_NETWORK = "bot-bottle-gateway"
# mitmproxy's CA dir in the bundle. The host's gateway-CA dir (see
# `host_gateway_ca_dir`) is bind-mounted here so the gateway's self-generated
# CA stays STABLE across container recreation — every agent installs this one
# CA to trust the shared gateway's TLS interception, so it must not rotate when
# the gateway restarts. A host bind-mount rather than a named volume: a named
# volume is silently wiped by `docker volume prune`, minting a fresh CA that
# breaks every running bottle (issue #450).
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
# The CA material mitmproxy writes into its confdir. mitmproxy reuses these on
# startup when present and generates them only on first run, so persisting them
# is what makes the CA stable; deleting them (see `rotate_gateway_ca`) forces a
# fresh CA on the next start. `mitmproxy-ca.pem` (cert + private key) is the
# signing identity; the rest are derived encodings agents/clients consume.
GATEWAY_CA_GLOB = "mitmproxy-ca*"
# The gateway data-plane image + its Dockerfile. Kept as a local constant
# rather than imported from the backend layer, which would drag
# the whole backend layer into the lean orchestrator (see #359); unify when
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
_REPO_ROOT = Path(__file__).resolve().parents[2]
def _host_db_dir() -> str:
"""The host DB directory (created if missing), for the gateway's
supervise-DB bind-mount."""
db_dir = host_db_path().parent
db_dir.mkdir(parents=True, exist_ok=True)
return str(db_dir)
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
"""Delete the persisted mitmproxy CA so the next gateway start mints a
fresh one the explicit, deliberate CA-rollover path (issue #450).
Persistence keeps the CA stable across restarts precisely because mitmproxy
reuses the on-disk CA; rotation is therefore just removing that material.
Returns the files removed (empty when there was no CA yet); idempotent.
This only clears the on-disk CA. It does NOT stop the running gateway (whose
mitmproxy still holds the old CA in memory) or re-provision agents the
caller recreates the gateway to mint the new CA and re-attaches bottles.
`rotate-ca` on the orchestrator CLI wires those steps together."""
ca_dir = ca_dir if ca_dir is not None else host_gateway_ca_dir()
removed: list[Path] = []
for path in sorted(ca_dir.glob(GATEWAY_CA_GLOB)):
path.unlink()
removed.append(path)
return removed
class GatewayError(Exception):
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
class Gateway(abc.ABC):
"""Lifecycle of the single per-host gateway. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
return
@abc.abstractmethod
def ensure_running(self) -> None:
"""Start the gateway if it isn't already up. Idempotent: a no-op
when it's already running (that's the whole point one per host).
Assumes the image exists call `ensure_built()` first."""
@abc.abstractmethod
def is_running(self) -> bool:
"""True iff the gateway instance is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the gateway. Idempotent — absent is success."""
from ...gateway import (
Gateway, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, GATEWAY_DOCKERFILE,
REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME, DEFAULT_CA_TIMEOUT_SECONDS,
CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
)
class DockerGateway(Gateway):
"""The consolidated gateway as a single, fixed-name Docker container.
@@ -161,7 +46,7 @@ class DockerGateway(Gateway):
# construct-then-read-CA path (`ca_cert_pem` on an already-running
# container), which never launches a container.
self._orchestrator_url = orchestrator_url
self._build_context = build_context or _REPO_ROOT
self._build_context = build_context or REPO_ROOT
self._dockerfile = dockerfile
# Ports published on the host (0.0.0.0). Used by the Firecracker
# backend's dev-harness gateway so VMs can reach it via their TAP link;
@@ -255,11 +140,10 @@ class DockerGateway(Gateway):
# container recreation AND docker volume pruning (agents trust it)
# — see host_gateway_ca_dir / issue #450.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# Share the one host DB: the supervise daemon queues proposals
# into the same file the orchestrator (and the operator, over
# HTTP) reads — no second, disconnected DB in the container.
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
# No DB mount: the data plane (egress / supervise / git-gate) reaches
# the supervise queue over the control-plane RPC and never opens
# bot-bottle.db, so the gateway container gets no file handle on it
# (PRD 0070 / issue #469).
]
for port in self._host_port_bindings:
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
@@ -268,11 +152,14 @@ class DockerGateway(Gateway):
# policy against the control plane per request (guaranteed non-empty by
# the check above).
argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"]
# ...and present the control-plane secret on those /resolve calls (the
# control plane requires it). Bare `--env NAME` keeps the value off argv
# / `docker inspect`; only the gateway (not the agent) is given it.
argv += ["--env", CONTROL_PLANE_TOKEN_ENV]
run_env[CONTROL_PLANE_TOKEN_ENV] = host_control_plane_token()
# ...presenting a role-scoped `gateway` token (a signed JWT minted from
# the host signing key) on those calls. The gateway never receives the
# signing key — only this pre-minted token, which it cannot rewrite into
# a `cli` token, so a compromised data-plane process can't drive the
# operator routes (issue #469 review). Bare `--env NAME` keeps the value
# off argv / `docker inspect`; only the gateway (not the agent) is given it.
argv += ["--env", ORCHESTRATOR_AUTH_JWT_ENV]
run_env[ORCHESTRATOR_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_orchestrator_token())
argv.append(self.image_ref)
proc = run_docker(argv, env=run_env)
if proc.returncode != 0:
@@ -294,16 +181,9 @@ class DockerGateway(Gateway):
f"gateway CA cert not available after {timeout:g}s: "
f"{proc.stderr.strip() or 'empty'}"
)
time.sleep(_CA_POLL_SECONDS)
time.sleep(CA_POLL_SECONDS)
def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name])
if proc.returncode != 0 and "No such container" not in proc.stderr:
raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
__all__ = [
"Gateway", "DockerGateway", "GatewayError", "rotate_gateway_ca",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
]
raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
@@ -17,7 +17,7 @@ from __future__ import annotations
import re
from typing import Protocol
from ...docker_cmd import run_docker
from .util import run_docker
from ...git_gate import GitGatePlan, git_gate_render_provision
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
+261
View File
@@ -0,0 +1,261 @@
"""The per-host infra container for the docker backend (PRD 0070).
Runs both the orchestrator control plane and the gateway data plane inside a
single `bot-bottle-infra` container on the shared gateway network the docker
analogue of the macOS infra container (`backend/macos_container/infra.py`) and
the Firecracker infra VM (`backend/firecracker/infra_vm.py`). `gateway_init` is
PID 1 and supervises both; the infra container is an idempotent per-host
singleton.
The combined container replaces the prior two-container split
(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches the
control plane via a published loopback port; gateway daemons reach it over
127.0.0.1 (same container).
The shared, backend-neutral pieces (control-plane port, startup timeout,
`OrchestratorStartError`, `source_hash`) live in `orchestrator.lifecycle`.
"""
from __future__ import annotations
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from ... import log
from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker
from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
bot_bottle_root,
host_orchestrator_token,
host_gateway_ca_dir,
)
from ...gateway import (
GATEWAY_DOCKERFILE,
GATEWAY_IMAGE,
GATEWAY_NETWORK,
GatewayError,
MITMPROXY_HOME,
)
from ...orchestrator.lifecycle import (
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
OrchestratorStartError,
source_hash,
)
INFRA_NAME = "bot-bottle-infra"
INFRA_LABEL = "bot-bottle-infra=1"
# The combined infra image: gateway data plane + orchestrator content.
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator).
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest")
INFRA_DOCKERFILE = "Dockerfile.infra"
# Baked as a container label so `ensure_running` can detect whether the
# running container is executing the current bind-mounted source.
INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash"
# Orchestrator image: the single canonical definition of the control-plane
# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git).
# Used as a build intermediate: `Dockerfile.infra` COPY --from this image.
ORCHESTRATOR_IMAGE = os.environ.get(
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# The gateway daemons + orchestrator the infra container runs.
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS).
_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
# The bind-mount path for the live control-plane source inside the
# container. Separate from /app so the gateway's baked scripts
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount inside the container (DB + state). The
# orchestrator control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT
# -> host_db_path()); it is the ONLY process in the container with a file
# handle on it (PRD 0070 / issue #469).
_ROOT_IN_CONTAINER = "/bot-bottle-root"
_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
_REPO_ROOT = Path(__file__).resolve().parents[3]
class DockerInfraService:
"""Manages the single per-host docker infra container (control plane +
gateway). Callers only need `ensure_running()` + `url`.
`infra_name` / `infra_label` let callers run independent infra containers
on the same host without name collisions (e.g. isolated integration tests
that can't share the production INFRA_NAME singleton)."""
def __init__(
self,
*,
port: int = DEFAULT_PORT,
network: str = GATEWAY_NETWORK,
image: str = INFRA_IMAGE,
repo_root: Path = _REPO_ROOT,
host_root: Path | None = None,
infra_name: str = INFRA_NAME,
infra_label: str = INFRA_LABEL,
) -> None:
self.port = port
self.network = network
self.image = image
self._repo_root = repo_root
self._host_root = host_root or bot_bottle_root()
self._infra_name = infra_name
self._infra_label = infra_label
@property
def url(self) -> str:
"""Host-side control-plane URL (published loopback port)."""
return f"http://127.0.0.1:{self.port}"
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
try:
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _container_running(self, name: str) -> bool:
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
return name in proc.stdout.split()
def _infra_source_current(self, current_hash: str) -> bool:
"""True iff the running infra container was started from the current
bind-mounted source. Mirrors the macOS backend's `_source_current`."""
if not self._container_running(self._infra_name):
return False
proc = run_docker([
"docker", "inspect", "--format",
"{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}",
self._infra_name,
])
if proc.returncode != 0:
return True # can't compare → don't churn a working container
return proc.stdout.strip() == current_hash
def _ensure_network(self) -> None:
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
return
proc = run_docker(["docker", "network", "create", self.network])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def _build_images(self) -> None:
"""Build the gateway base, the orchestrator intermediate, then the
infra image. All are cache-aware: a no-op when nothing changed."""
for tag, dockerfile in (
(GATEWAY_IMAGE, GATEWAY_DOCKERFILE),
(ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE),
(self.image, INFRA_DOCKERFILE),
):
argv = ["docker", "build", "-t", tag,
"-f", str(self._repo_root / dockerfile),
str(self._repo_root)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache")
proc = run_docker(argv)
if proc.returncode != 0:
raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")
def _run_infra_container(self, current_hash: str) -> None:
"""Start the combined infra container (idempotent: clears a stale
fixed-name container first). Labels the container with `current_hash`
so a later `ensure_running` can detect a real code change."""
self._ensure_network()
run_docker(["docker", "rm", "--force", self._infra_name])
_signing_key = host_orchestrator_token()
proc = run_docker([
"docker", "run", "--detach",
"--name", self._infra_name,
"--label", self._infra_label,
"--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}",
"--network", self.network,
# Host CLI reaches the control plane here (loopback only).
# gateway_init always starts the orchestrator on DEFAULT_PORT (8099)
# inside the container; self.port is the host-side published port.
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
# Persist the mitmproxy CA on the host so it survives container
# recreation AND docker volume pruning (issue #450): every agent
# trusts this one CA, so a fresh one would break all running bottles.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# Live control-plane source, mounted to a path that does not
# overlay the gateway's baked /app scripts.
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
# PYTHONPATH lets the orchestrator (and other Python daemons)
# import the live source ahead of the installed package.
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
# Orchestrator registry DB on the host (sole writer: control plane).
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
# Control-plane signing key (orchestrator: verifies tokens) + the
# pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init
# scopes each to its process, so a compromised data-plane daemon never
# sees the key and can't mint a `cli` token (issue #469 review).
"--env", ORCHESTRATOR_TOKEN_ENV,
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
# Gateway daemons reach the orchestrator over loopback at its
# fixed internal port (DEFAULT_PORT), independent of self.port.
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
# Opt the orchestrator into gateway_init's supervise tree.
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
self.image,
], env={
**os.environ,
ORCHESTRATOR_TOKEN_ENV: _signing_key,
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
})
if proc.returncode != 0:
raise OrchestratorStartError(
f"infra container failed to start: {proc.stderr.strip()}"
)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> str:
"""Ensure the infra container (control plane + gateway) is up; return
the host control-plane URL. Idempotent a healthy container on current
source is left untouched. Raises `OrchestratorStartError` on timeout."""
self._build_images()
current_hash = source_hash(self._repo_root)
if self.is_healthy() and self._infra_source_current(current_hash):
return self.url
log.info("starting infra container", context={"name": self._infra_name})
self._run_infra_container(current_hash)
deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline:
if self.is_healthy():
log.info("infra container healthy", context={"url": self.url})
return self.url
time.sleep(_HEALTH_POLL_SECONDS)
raise OrchestratorStartError(
f"infra container at {self.url} did not become healthy within {startup_timeout:g}s"
)
def stop(self) -> None:
"""Remove the infra container (idempotent)."""
run_docker(["docker", "rm", "--force", self._infra_name])
__all__ = [
"DockerInfraService",
"INFRA_NAME",
"INFRA_IMAGE",
"INFRA_SOURCE_HASH_LABEL",
"ORCHESTRATOR_IMAGE",
]
+14 -13
View File
@@ -37,11 +37,8 @@ from pathlib import Path
from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...egress import egress_resolve_token_values
from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...egress import Egress
from ...git_gate import GitGate
from ...image_cache import check_stale
from ...log import die, info, warn
from .. import BottleImages
@@ -64,9 +61,10 @@ from .compose import (
write_compose_file,
)
from .consolidated_compose import consolidated_agent_compose
from ...orchestrator.config_store import resolve_teardown_timeout
from ...orchestrator.store.config_store import resolve_teardown_timeout
from .consolidated_launch import launch_consolidated, teardown_consolidated
from ...orchestrator.gateway import DockerGateway
from .infra import INFRA_NAME
from .gateway import DockerGateway
# Where the repo root lives, for `docker build` context. Computed once.
@@ -125,7 +123,7 @@ def launch(
f"teardown failed for container {plan.container_name}"
f" (compose-down): {exc!r}"
)
revoke_git_gate_provisioned_keys(
GitGate().revoke_provisioned_keys(
_bottle_for_revoke, _git_gate_dir_for_revoke
)
@@ -134,7 +132,7 @@ def launch(
# provisioning the bottle's repos into the shared gateway.
git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys(
git_gate_plan = GitGate().provision_dynamic_keys(
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
)
@@ -145,7 +143,7 @@ def launch(
# handed to the orchestrator (in memory) for the gateway to inject —
# the agent never sees them.
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
token_values = Egress().resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
teardown_timeout = resolve_teardown_timeout()
@@ -159,11 +157,14 @@ def launch(
)
# Step 4: install the SHARED gateway CA into the agent (replaces the
# per-bottle CA) — read it out of the running gateway.
# per-bottle CA) — read it out of the running gateway. The consolidated
# flow runs the gateway data plane inside the per-host infra container
# (INFRA_NAME), so read the CA from there, not the legacy standalone
# gateway container name.
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
ca_dir.mkdir(parents=True, exist_ok=True)
ca_file = ca_dir / "gateway-ca.pem"
ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem())
ca_file.write_text(DockerGateway(name=INFRA_NAME).ca_cert_pem())
egress_plan = dataclasses.replace(
plan.egress_plan,
mitmproxy_ca_host_path=ca_file,
@@ -203,7 +204,7 @@ def launch(
# spec, value only in the subprocess env so it is never written to disk.
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
if plan.env_var_secret:
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
compose_env[ENV_VAR_SECRET_NAME] = plan.env_var_secret
info(
f"docker compose up -d (project {project}, agent on shared "
+1 -1
View File
@@ -19,7 +19,7 @@ from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...manifest import Manifest
from ...supervise import SupervisePlan
from ...supervisor.plan import SupervisePlan
from ...git_gate import GitGatePlan
def preflight() -> None:
+20 -5
View File
@@ -1,6 +1,6 @@
"""Docker host-side primitives used by DockerBottleBackend: probing
for docker on PATH, slugifying agent names, checking image/container
existence, and building images."""
"""Docker host-side primitives used by DockerBottleBackend: the lean
`run_docker` subprocess wrapper, probing for docker on PATH, slugifying agent
names, checking image/container existence, and building images."""
from __future__ import annotations
@@ -11,9 +11,24 @@ import shutil
import subprocess
from typing import Iterator
from ...docker_cmd import run_docker
from ...log import die, info
# from ...workspace import WorkspacePlan
def run_docker(
argv: list[str], *, env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a `docker` command, capturing stdout/stderr as text. Never raises on
a non-zero exit callers inspect `returncode` / `stderr` so they can stay
fail-closed or tolerate idempotent no-ops (e.g. removing an already-absent
container).
`env` sets the child process environment used to hand a secret to a bare
`--env NAME` flag (docker inherits its value from this process) so the value
never lands on argv or in `docker inspect`'s recorded command line."""
return subprocess.run(
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
check=False, env=env,
)
# Cap on the suffix the container-name conflict logic will try before
+1 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from ..bottle_state import egress_state_dir
from ..egress import EGRESS_ROUTES_FILENAME
from ..egress_addon_core import LOG_OFF, load_config
from ..gateway.egress.addon_core import LOG_OFF, load_config
class EgressApplyError(RuntimeError):
+20 -2
View File
@@ -1,7 +1,25 @@
"""Firecracker backend: Linux KVM microVM isolation (issue #342)."""
"""Firecracker backend: Linux KVM microVM isolation (issue #342).
Thin by design: `FirecrackerBottleBackend` is re-exported lazily via
`__getattr__`, so importing a leaf module under this package doesn't drag the
backend (and the framework it pulls) into memory.
"""
from __future__ import annotations
from .backend import FirecrackerBottleBackend
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .backend import FirecrackerBottleBackend
def __getattr__(name: str) -> Any:
if name == "FirecrackerBottleBackend":
from .backend import FirecrackerBottleBackend
globals()[name] = FirecrackerBottleBackend
return FirecrackerBottleBackend
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["FirecrackerBottleBackend"]
+2 -2
View File
@@ -17,7 +17,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...manifest import Manifest
from ...supervise import SupervisePlan
from ...supervisor.plan import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -120,4 +120,4 @@ class FirecrackerBottleBackend(
def ensure_orchestrator(self) -> str:
from . import infra_vm
return infra_vm.ensure_running().control_plane_url
return infra_vm.ensure_running().orchestrator_url
@@ -38,7 +38,7 @@ from ...orchestrator.lifecycle import (
OrchestratorStartError, # re-exported so callers can catch it
)
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..consolidated_util import (
provision_bottle,
teardown_consolidated as _teardown_util,
@@ -124,7 +124,7 @@ def launch_consolidated(
provision its git-gate state into the gateway VM. Returns the context the
agent-VM launch needs. Raises on failure the caller tears down."""
infra = infra_vm.ensure_running()
url = infra.control_plane_url
url = infra.orchestrator_url
client = OrchestratorClient(url)
_reprovision_running_bottles(client)
@@ -66,7 +66,7 @@ def infra_artifact_version(init_script: str, *, repo_root: Path = _REPO_ROOT) ->
The package is `COPY bot_bottle /app/bot_bottle`'d wholesale into the image,
so hash *every* regular file under it not just `*.py`. Non-Python inputs
(e.g. `egress_entrypoint.sh`, `netpool.defaults.env`) are baked in too, and
(e.g. `gateway/egress/entrypoint.sh`, `netpool.defaults.env`) are baked in too, and
a change to one must bump the version or a launch host could boot a stale
rootfs whose code differs from its checkout. `__pycache__`/`.pyc` are the
only exclusions build artifacts, never copied."""
+88 -17
View File
@@ -33,11 +33,19 @@ from pathlib import Path
from typing import Generator
from ...log import die, info
from ...paths import host_orchestrator_token
from .. import util as backend_util
from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError
from . import firecracker_vm, infra_artifact, netpool, util
# The infra VM's control-plane signing-key path on its persistent /dev/vdb
# volume (mounted at BOT_BOTTLE_ROOT=/var/lib/bot-bottle). The launcher seeds
# this file with the host-canonical key BEFORE boot, so the VM verifies tokens
# with the same key the host CLI signs with — the host token file stays the
# single source of truth, never clobbered per-backend (issue #469 review).
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/orchestrator-token"
# The single infra-VM image: gateway data plane + baked control-plane source
# (Dockerfile.infra FROM the gateway image). Built from source by default;
# a pull-from-registry mode lands later.
@@ -46,7 +54,7 @@ _GATEWAY_IMAGE = "bot-bottle-gateway:latest"
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
_REPO_ROOT = Path(__file__).resolve().parents[3]
CONTROL_PLANE_PORT = 8099
ORCHESTRATOR_PORT = 8099
# Gateway data-plane ports (agent-facing): egress proxy, supervise MCP,
# git-http. Reached by agent VMs over VM-to-VM routing (added next).
EGRESS_PORT = 9099
@@ -64,6 +72,10 @@ _INFRA_RESOLVER = "1.1.1.1"
_HEALTH_TIMEOUT_SECONDS = 45.0
_HEALTH_POLL_SECONDS = 0.5
_CA_TIMEOUT_SECONDS = 30.0
# How long the launcher retries pushing the signing key while the guest's SSH
# comes up. Below the init's own wait window, so a failed push dies here first.
_SIGNING_KEY_PUSH_TIMEOUT_SECONDS = 30.0
_SIGNING_KEY_PUSH_POLL_SECONDS = 0.5
@dataclass
@@ -78,8 +90,8 @@ class InfraVm:
vm: firecracker_vm.VmHandle | None = None
@property
def control_plane_url(self) -> str:
return f"http://{self.guest_ip}:{CONTROL_PLANE_PORT}"
def orchestrator_url(self) -> str:
return f"http://{self.guest_ip}:{ORCHESTRATOR_PORT}"
def terminate(self) -> None:
"""Stop the infra VM — via the live handle if we booted it, else the
@@ -162,7 +174,7 @@ def ensure_running() -> InfraVm:
flock, so two simultaneous first launches don't both boot on the same
rootfs/PID. The healthy fast-path takes no lock."""
slot = netpool.orch_slot()
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
url = f"http://{slot.guest_ip}:{ORCHESTRATOR_PORT}"
key = _infra_dir() / "id_ed25519"
want = _expected_version()
if _adoptable(key, url, want):
@@ -237,7 +249,13 @@ def boot() -> InfraVm:
data_drive=_ensure_registry_volume(),
)
_pid_file().write_text(str(vm.process.pid))
return InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm)
infra = InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm)
# Push the host-canonical control-plane signing key into the guest over SSH
# (the init waits for it before starting the control plane). The host token
# file stays the single source of truth, so a co-running Docker/macOS control
# plane keeps working (issue #469 review).
_push_signing_key(infra)
return infra
def _infra_dir() -> Path:
@@ -309,6 +327,33 @@ def _ensure_registry_volume() -> Path:
return vol
def _push_signing_key(infra: InfraVm) -> None:
"""Push the host-canonical control-plane signing key into the freshly booted
infra VM over SSH (atomic write), so its orchestrator verifies tokens with
the same key the host CLI signs from. Mirrors the existing
`persist_env_var_secret` transport. Retries until the guest's SSH is up (it
comes up before the init waits for this file); dies if it never lands, since
the VM then refuses to start its control plane rather than run OPEN."""
key = host_orchestrator_token()
push = (
f"umask 077; cat > {_GUEST_SIGNING_KEY_PATH}.tmp "
f"&& mv {_GUEST_SIGNING_KEY_PATH}.tmp {_GUEST_SIGNING_KEY_PATH}"
)
deadline = time.monotonic() + _SIGNING_KEY_PUSH_TIMEOUT_SECONDS
last = ""
while time.monotonic() < deadline:
proc = subprocess.run(
util.ssh_base_argv(infra.private_key, infra.guest_ip) + [push],
input=key, capture_output=True, text=True, check=False,
)
if proc.returncode == 0:
return
last = proc.stderr.strip()
time.sleep(_SIGNING_KEY_PUSH_POLL_SECONDS)
die("could not push the control-plane signing key to the infra VM "
f"(its control plane will not start): {last or '<no stderr>'}")
def _stable_keypair() -> tuple[Path, str]:
"""The infra VM's SSH keypair — generated once and reused, so any later
launcher can SSH in (fetch CA / provision) even though a different process
@@ -428,7 +473,7 @@ def wait_for_health(
) -> None:
"""Poll the control plane's /health until it answers 200 or the deadline
passes. Dies (with the console tail) if the VMM exits early."""
url = f"{infra.control_plane_url}/health"
url = f"{infra.orchestrator_url}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if infra.vm is not None and not infra.vm.is_alive():
@@ -437,7 +482,7 @@ def wait_for_health(
try:
with urllib.request.urlopen(url, timeout=1.0) as resp:
if resp.status == 200:
info(f"infra control plane healthy at {infra.control_plane_url}")
info(f"infra control plane healthy at {infra.orchestrator_url}")
return
except (urllib.error.URLError, TimeoutError, OSError):
pass
@@ -487,17 +532,43 @@ mount -t ext4 /dev/vdb /var/lib/bot-bottle 2>/dev/null || true
# Control plane. Source is baked at /app; the package is stdlib-only.
cd /app
BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
# Wait for the launcher to push the host-canonical control-plane signing key
# over SSH, then hand it ONLY to the orchestrator (to verify tokens); the
# data-plane daemons get a pre-minted `gateway` JWT they present, never the key.
# If it never arrives, REFUSE to start the control plane rather than run OPEN —
# open mode would grant every unauthenticated caller the `cli` role, and a
# compromised egress / supervise / git-http daemon in this same VM (reaching the
# orchestrator over 127.0.0.1, past the nft boundary that only fences off the
# separate agent VM) could then drive the operator routes (issue #469).
CP_KEY=""
i=0
while [ "$i" -lt 600 ]; do
CP_KEY=$(cat /var/lib/bot-bottle/orchestrator-token 2>/dev/null)
[ -n "$CP_KEY" ] && break
i=$((i + 1))
sleep 0.1
done
if [ -z "$CP_KEY" ]; then
echo "infra: control-plane signing key never arrived; refusing to start the control plane (would run OPEN)" >&2
else
chmod 600 /var/lib/bot-bottle/orchestrator-token 2>/dev/null || true
GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.orchestrator_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))')
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_ORCHESTRATOR_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {ORCHESTRATOR_PORT} --broker stub &
# Gateway data plane, multi-tenant: each request resolves source-IP ->
# policy against the local control plane. The VM backend reaches git over
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
# entrypoint the consolidated model doesn't use) is left out.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\
python3 -m bot_bottle.gateway_init &
# Gateway data plane, multi-tenant: each request resolves source-IP ->
# policy against the local control plane. The VM backend reaches git over
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
# entrypoint the consolidated model doesn't use) is left out. No
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469). It
# presents the pre-minted `gateway` JWT; gateway_init keeps the signing key
# out of the data-plane daemons' env.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{ORCHESTRATOR_PORT} \\
BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT="$GW_JWT" \\
python3 -m bot_bottle.gateway.bootstrap &
fi
# Reap as PID 1; children are backgrounded, so `wait` blocks.
while : ; do wait ; done
+9 -15
View File
@@ -38,24 +38,18 @@ from ...bottle_state import (
git_gate_state_dir,
read_committed_image,
)
from ...egress import (
egress_agent_env_entries,
egress_resolve_token_values,
)
from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...egress import Egress
from ...git_gate import GitGate
from ...image_cache import check_stale_path
from ...log import die, info, warn
from ...supervise import SUPERVISE_PORT
from ...supervisor.types import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from . import firecracker_vm, image_builder, isolation_probe, netpool, util
from .bottle import FirecrackerBottle
from .bottle_plan import FirecrackerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.store.config_store import resolve_teardown_timeout
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from .consolidated_launch import (
launch_consolidated,
persist_env_var_secret,
@@ -86,7 +80,7 @@ def launch(
except BaseException as exc: # noqa: W0718 - teardown must continue
teardown_exc = exc
warn(f"firecracker teardown failed: {exc!r}")
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
GitGate().revoke_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
if teardown_exc is not None:
raise teardown_exc
@@ -97,7 +91,7 @@ def launch(
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any.
git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys(
git_gate_plan = GitGate().provision_dynamic_keys(
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
)
@@ -113,7 +107,7 @@ def launch(
# (in memory) for the gateway to inject — the agent never sees them.
# Attribution is by the VM's guest IP (unspoofable via /31 TAP + nft).
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
token_values = Egress().resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
teardown_timeout = resolve_teardown_timeout()
@@ -287,7 +281,7 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url
if plan.env_var_secret:
env[ENV_VAR_SECRET_NAME] = plan.env_var_secret
for entry in egress_agent_env_entries(plan.egress_plan):
for entry in Egress().agent_env_entries(plan.egress_plan):
key, _, value = entry.partition("=")
env[key] = value
env.update(plan.agent_provision.guest_env)
@@ -9,7 +9,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...manifest import Manifest
from ...supervise import SupervisePlan
from ...supervisor.plan import SupervisePlan
from .. import BottleSpec
from . import util
from .bottle_plan import FirecrackerBottlePlan
+23 -4
View File
@@ -1,10 +1,29 @@
"""macOS Apple Container backend.
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
the Apple `container` CLI integration; launch remains gated until the
gateway network enforcement shape is implemented.
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns the Apple
`container` CLI integration; launch remains gated until the gateway network
enforcement shape is implemented.
Thin by design: `MacosContainerBottleBackend` is re-exported lazily via
`__getattr__`, so importing a leaf module under this package doesn't drag the
backend (and the framework it pulls) into memory.
"""
from .backend import MacosContainerBottleBackend
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .backend import MacosContainerBottleBackend
def __getattr__(name: str) -> Any:
if name == "MacosContainerBottleBackend":
from .backend import MacosContainerBottleBackend
globals()[name] = MacosContainerBottleBackend
return MacosContainerBottleBackend
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["MacosContainerBottleBackend"]
@@ -10,7 +10,7 @@ from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from . import cleanup as _cleanup
@@ -102,7 +102,7 @@ class MacosContainerBottleBackend(
(`supervise`) call when no control plane is running yet. Mirrors
firecracker's infra-VM bring-up."""
from .infra import MacosInfraService
return MacosInfraService().ensure_running().control_plane_url
return MacosInfraService().ensure_running().orchestrator_url
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
return _cleanup.prepare_cleanup()
@@ -39,7 +39,7 @@ from ...git_gate import GitGatePlan
from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..consolidated_util import (
provision_bottle,
teardown_consolidated as _teardown_util,
@@ -90,7 +90,7 @@ def ensure_gateway(
service = service or MacosInfraService()
infra = service.ensure_running()
endpoint = GatewayEndpoint(
orchestrator_url=infra.control_plane_url,
orchestrator_url=infra.orchestrator_url,
gateway_ip=infra.gateway_ip,
gateway_ca_pem=service.ca_cert_pem(),
network=service.network,
@@ -12,7 +12,7 @@ from __future__ import annotations
import os
from ...orchestrator.gateway import GatewayError
from ...gateway import GatewayError
from . import util as container_mod
# The shared host-only network the infra container and every agent bottle sit
+29 -17
View File
@@ -41,17 +41,19 @@ from dataclasses import dataclass
from pathlib import Path
from ... import log
from ...orchestrator.gateway import GATEWAY_CA_CERT, MITMPROXY_HOME
from ...gateway import GATEWAY_CA_CERT, MITMPROXY_HOME
from ...orchestrator.lifecycle import (
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
OrchestratorStartError,
source_hash,
)
from ...orchestrator_auth import ROLE_GATEWAY, mint
from ...paths import (
CONTROL_PLANE_TOKEN_ENV,
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
HOST_DB_FILENAME,
host_control_plane_token,
host_orchestrator_token,
host_gateway_ca_dir,
)
from .. import util as backend_util
@@ -100,10 +102,12 @@ def _init_script(port: int) -> str:
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
"--broker stub ) &\n"
# Gateway data plane, multi-tenant against the local control plane.
# Gateway data plane, multi-tenant against the local control plane. No
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 -m bot_bottle.gateway_init ) &\n"
f"python3 -m bot_bottle.gateway.bootstrap ) &\n"
"while : ; do wait ; done\n"
)
@@ -113,7 +117,7 @@ class InfraEndpoint:
"""How to reach the running infra container. The control plane and the
gateway are the same container, so one address serves both."""
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration
orchestrator_url: str # http://<infra ip>:8099 — host CLI + registration
gateway_ip: str # same container; agents' proxy / git-http / MCP target
@@ -177,7 +181,7 @@ class MacosInfraService:
return None
url = self._resolve_url()
if url and self.is_healthy(url):
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
return InfraEndpoint(orchestrator_url=url, gateway_ip=_ip_of(url))
return None
def ensure_built(self) -> None:
@@ -235,18 +239,26 @@ class MacosInfraService:
# Baked onto the container so `_source_current` can detect a real
# control-plane code change and recreate.
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
# The control-plane secret, for BOTH the control plane (to require
# it) and the gateway's PolicyResolver (to present it) — they share
# this one container. Bare `--env NAME` inherits the value from the
# run process below, so the secret never lands on argv or in
# `container inspect`'s command line. The agent runs in a SEPARATE
# container that is never given this var, which is the whole point.
"--env", CONTROL_PLANE_TOKEN_ENV,
# The control-plane signing key (control plane: verifies tokens) and
# the pre-minted `gateway` JWT (the gateway's PolicyResolver: presents
# it) — they share this one container, and gateway_init scopes each to
# its process so a compromised data-plane daemon never sees the key
# (issue #469 review). Bare `--env NAME` inherits the value from the
# run process below, so neither lands on argv or in `container
# inspect`'s command line. The agent runs in a SEPARATE container that
# is never given these vars, which is the whole point.
"--env", ORCHESTRATOR_TOKEN_ENV,
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
"--entrypoint", "sh",
self.image,
"-c", _init_script(self.port),
]
run_env = {**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()}
_signing_key = host_orchestrator_token()
run_env = {
**os.environ,
ORCHESTRATOR_TOKEN_ENV: _signing_key,
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
}
result = container_mod.run_container_argv(argv, env=run_env)
if result.returncode != 0:
raise OrchestratorStartError(
@@ -260,7 +272,7 @@ class MacosInfraService:
url = self._resolve_url()
if url and self.is_healthy(url):
log.info("infra container healthy", context={"url": url})
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
return InfraEndpoint(orchestrator_url=url, gateway_ip=_ip_of(url))
if time.monotonic() >= deadline:
raise OrchestratorStartError(
f"infra container did not become healthy within "
@@ -294,7 +306,7 @@ def _ip_of(url: str) -> str:
return url.split("://", 1)[-1].rsplit(":", 1)[0]
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str:
def probe_orchestrator_url(port: int = DEFAULT_PORT) -> str:
"""The running infra container's control-plane URL, or "" if it isn't up.
Used by host-side control-plane discovery (`discover_orchestrator_url`);
safe to call on any host returns "" when the container or the `container`
+10 -16
View File
@@ -44,19 +44,13 @@ from ...bottle_state import (
git_gate_state_dir,
read_committed_image,
)
from ...egress import (
egress_agent_env_entries,
egress_resolve_token_values,
)
from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...egress import Egress
from ...git_gate import GitGate
from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...image_cache import check_stale
from ...log import die, info, warn
from .. import BottleImages
from ...supervise import SUPERVISE_PORT
from ...supervisor.types import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from . import util as container_mod
@@ -68,8 +62,8 @@ from .gateway_hosts import (
)
from . import nested_containers as nested_containers_mod
from .bottle_plan import MacosContainerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret
from ...orchestrator.store.config_store import resolve_teardown_timeout
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret
from .consolidated_launch import (
GatewayEndpoint,
ensure_gateway,
@@ -151,7 +145,7 @@ def launch(
except BaseException as exc: # noqa: W0718 - teardown must continue
teardown_exc = exc
warn(f"macos-container teardown failed: {exc!r}")
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
GitGate().revoke_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
if teardown_exc is not None:
raise teardown_exc
@@ -194,7 +188,7 @@ def launch(
f"{endpoint.network}"
)
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
token_values = Egress().resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
teardown_timeout = resolve_teardown_timeout()
@@ -282,7 +276,7 @@ def _provision_git_gate_keys(
) -> MacosContainerBottlePlan:
if not plan.git_gate_plan.upstreams:
return plan
git_gate_plan = provision_git_gate_dynamic_keys(
git_gate_plan = GitGate().provision_dynamic_keys(
plan.manifest.bottle,
plan.git_gate_plan,
git_gate_state_dir(plan.slug),
@@ -454,7 +448,7 @@ def _agent_env_entries(
# so the secret value never lands on argv.
for name in sorted(plan.forwarded_env.keys()):
env.append(name)
env.extend(egress_agent_env_entries(plan.egress_plan))
env.extend(Egress().agent_env_entries(plan.egress_plan))
return tuple(env)
@@ -8,7 +8,7 @@ from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest
from .. import BottleSpec
from . import util as container_mod
+3 -2
View File
@@ -28,7 +28,8 @@ from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan
from ..log import die
from ..manifest import Manifest, ManifestBottle
from ..supervise import Supervise, SupervisePlan
from ..supervisor.plan import SupervisePlan
from ..orchestrator.supervisor import Supervisor
from . import BottleSpec
@@ -101,7 +102,7 @@ def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None
return None
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
return Supervise().prepare(slug, supervise_dir)
return Supervisor().prepare(slug, supervise_dir)
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
+188
View File
@@ -0,0 +1,188 @@
"""Backend registry, selection, and active-agent enumeration.
Resolves which bottle backend to use (explicit name / `BOT_BOTTLE_BACKEND` /
auto-select), and enumerates running agents across every available backend. The
three concrete backends are imported lazily inside `_get_backends` so this
module and anything that only needs to *select* a backend stays cheap.
"""
from __future__ import annotations
import os
import sys
from typing import Any
from ..log import die, info, warn
from ..util import read_tty_line
from .base import ActiveAgent, BottleBackend
# _backends is None until the first call to _get_backends(), at which
# point all three concrete backend classes are imported and instantiated.
# Keeping the imports out of module scope means that importing any
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
# firecracker and macos-container implementations into memory.
#
# Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it
# is not None, so test fakes take effect without triggering real imports.
_backends: dict[str, BottleBackend[Any, Any]] | None = None
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
"""Return the registry of all backend instances, loading lazily on first call."""
global _backends # pylint: disable=global-statement
if _backends is None:
from .docker import DockerBottleBackend
from .firecracker import FirecrackerBottleBackend
from .macos_container import MacosContainerBottleBackend
_backends = {
"docker": DockerBottleBackend(),
"firecracker": FirecrackerBottleBackend(),
"macos-container": MacosContainerBottleBackend(),
}
return _backends
def get_bottle_backend(
name: str | None = None,
*,
prompt: bool = True,
) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend.
`name` precedence:
1. explicit arg (e.g. resume passes the recorded backend name)
2. BOT_BOTTLE_BACKEND env var
3. auto-selection: VM backend first, docker fallback with prompt
`prompt` controls whether auto-selection may block on an interactive
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
non-interactive contexts (headless launches, CI) so the call dies
with an actionable message instead of hanging.
Dies with a pointer at the known backends if the chosen name
isn't implemented."""
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
if resolved is None:
resolved = _auto_select_backend(prompt=prompt)
backends = _get_backends()
if resolved not in backends:
known = ", ".join(sorted(backends))
die(f"unknown backend {resolved!r}; known backends: {known}")
return backends[resolved]
def _platform_vm_suggestion() -> str:
"""Platform-appropriate VM backend name for install suggestions."""
return "macos-container" if sys.platform == "darwin" else "firecracker"
def _print_vm_install_instructions() -> None:
"""Print platform-appropriate VM backend install instructions to stderr."""
vm = _platform_vm_suggestion()
if vm == "macos-container":
info("Install Apple Container: https://github.com/apple/container/releases")
info("Then start the service: container system start")
else:
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
info("Configure the host: ./cli.py backend setup")
def _auto_select_backend(prompt: bool = True) -> str:
"""Tier-1 / tier-2 backend auto-selection.
Tier 1: VM backend macos-container on macOS when Apple Container is
installed; firecracker on KVM-capable Linux even before the binary is
present (its preflight prints an install pointer).
Tier 2: docker, with a security warning and an interactive prompt.
When `prompt=False` (headless / CI), dies with an actionable message
instead of blocking on a TTY read. When docker is also absent, prints
VM install instructions and exits.
"""
# --- Tier 1: VM backend -----------------------------------------
if has_backend("macos-container"):
return "macos-container"
# A KVM-capable Linux host defaults to firecracker even when the
# `firecracker` binary isn't installed yet: selecting it here routes
# start through firecracker's preflight, which prints an install
# pointer, instead of silently falling back to docker.
from .firecracker import FirecrackerBottleBackend
if FirecrackerBottleBackend.is_host_capable():
return "firecracker"
# --- Tier 2: docker fallback ------------------------------------
if not has_backend("docker"):
info("No backend available on this host.")
_print_vm_install_instructions()
die("no backend available; install a VM backend and re-run")
vm = _platform_vm_suggestion()
warn(
"docker is less secure than VM backends — "
"containers share the host kernel."
)
if not prompt:
die(
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
f"with docker, or install the {vm!r} backend."
)
sys.stderr.write(
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
f" [i] show {vm} install instructions and exit\n"
" [d] use docker anyway\n"
" [q] quit\n"
"bot-bottle: choice [i/d/q]: "
)
sys.stderr.flush()
reply = read_tty_line().strip().lower()
if reply == "d":
return "docker"
if reply == "i":
_print_vm_install_instructions()
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
argparse (`--backend` choices) and the dashboard's backend
picker."""
return tuple(sorted(_get_backends()))
def has_backend(name: str) -> bool:
"""Whether the named backend's runtime prerequisites are
available on the current host. Cross-backend callers (list,
cleanup) skip unavailable backends so a docker-only host
doesn't fail when the firecracker backend isn't usable,
and vice versa.
Returns False for unknown names so callers can pass
arbitrary input without separate validation."""
backends = _get_backends()
if name not in backends:
return False
return backends[name].is_available()
def enumerate_active_agents() -> list[ActiveAgent]:
"""All currently-running agents, across every available
backend. Used by CLI `active` and the dashboard's agents
pane so neither has to know which backends exist. Skips
backends whose `is_available()` reports False.
Sorted by `(started_at, slug)` so the list is stable across
dashboard refresh ticks agents don't shift position while
the operator navigates with arrow keys. ISO 8601 timestamps
sort lexicographically in chronological order; `slug` is the
deterministic tiebreaker. Agents with missing metadata
(`started_at == ""`) sort first."""
out: list[ActiveAgent] = []
backends = _get_backends()
for name in sorted(backends):
if not backends[name].is_available():
continue
out.extend(backends[name].enumerate_active())
out.sort(key=lambda a: (a.started_at, a.slug))
return out
+9 -110
View File
@@ -1,116 +1,15 @@
"""Main CLI dispatcher.
"""bot-bottle CLI package.
Commands: active, backend, cleanup, commit, edit, init, list, resume, start, supervise
The subcommand handlers live in `commands/` and are assembled into the
COMMANDS registry by `commands/__init__.py`; the dispatcher `main()` lives
in `__main__.py`. They are re-exported here so `bot_bottle.cli.main`,
`bot_bottle.cli.COMMANDS`, and `bot_bottle.cli.NO_MIGRATION_COMMANDS` stay
importable (the repo-root `cli.py` entry point and the tests use them).
"""
from __future__ import annotations
import sys
from .__main__ import main
from .commands import COMMANDS, NO_MIGRATION_COMMANDS
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 .active import cmd_active
from .backend import cmd_backend
from .cleanup import cmd_cleanup
from .commit import cmd_commit
from .edit import cmd_edit
from .init import cmd_init
from .login import cmd_login
from .resume import cmd_resume
from .start import cmd_start
from .supervise import cmd_supervise
cmd_list = _list_mod.cmd_list
COMMANDS = {
"active": cmd_active,
"backend": cmd_backend,
"cleanup": cmd_cleanup,
"commit": cmd_commit,
"edit": cmd_edit,
"init": cmd_init,
"list": cmd_list,
"login": cmd_login,
"resume": cmd_resume,
"start": cmd_start,
"supervise": cmd_supervise,
}
# Commands that manage host prerequisites (or are otherwise store-free) and
# must run before — or without — a migrated DB. `backend` provisions/probes
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
# gating it on the schema breaks preflight on a fresh CI runner where stdin
# isn't a TTY and the migration prompt can't be answered.
NO_MIGRATION_COMMANDS = frozenset({"backend", "login"})
def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n")
sys.stderr.write(" active list currently-running bot-bottle bottles\n")
sys.stderr.write(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
sys.stderr.write(" list list available agents from bot-bottle.json\n")
sys.stderr.write(" login register this host with a bot-bottle console\n")
sys.stderr.write(
" resume re-launch a bottle by its identity "
"(continues state from PRD 0016)\n"
)
sys.stderr.write(
" start boot a container for a named agent and "
"attach an interactive session\n"
)
sys.stderr.write(
" supervise view + approve/modify/reject pending supervise "
"proposals (PRD 0013)\n\n"
)
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
if not argv:
usage()
return 2
command = argv[0]
rest = argv[1:]
if command in ("-h", "--help"):
usage()
return 0
handler = COMMANDS.get(command)
if handler is None:
usage()
die(f"unknown command: {command}")
mgr = StoreManager.instance()
if command not in NO_MIGRATION_COMMANDS and 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:
error(str(e))
return 1
except Die as e:
return e.code if isinstance(e.code, int) else 1
except KeyboardInterrupt:
return 130
__all__ = ["main", "COMMANDS", "NO_MIGRATION_COMMANDS"]
+55 -5
View File
@@ -1,15 +1,65 @@
"""Entry point for `python -m bot_bottle.cli`.
"""Entry point + dispatcher for `python -m bot_bottle.cli`.
`cli.py` at the repo root is the usual way in; this makes the package
runnable too, so the CLI works from an installed copy where there is no
`cli.py` on disk to point at.
Maps `bot-bottle <command>` to its handler in the COMMANDS registry
(`bot_bottle.cli.commands`), enforces the schema-migration gate, and
translates handler exceptions into process exit codes. The repo-root
`cli.py` is the usual way in; this makes the package runnable too, so the
CLI works from an installed copy where there is no `cli.py` on disk.
"""
from __future__ import annotations
import sys
from . import main
from ..errors import MissingEnvVarError
from ..log import Die, die, error
from ..manifest import ManifestError
from ..orchestrator.store.store_manager import StoreManager
from .commands import COMMANDS, NO_MIGRATION_COMMANDS
from .commands.help import cmd_help
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
if not argv:
cmd_help()
return 2
command = argv[0]
rest = argv[1:]
if command in ("-h", "--help"):
cmd_help()
return 0
handler = COMMANDS.get(command)
if handler is None:
cmd_help()
die(f"unknown command: {command}")
mgr = StoreManager.instance()
if command not in NO_MIGRATION_COMMANDS and 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:
error(str(e))
return 1
except Die as e:
return e.code if isinstance(e.code, int) else 1
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
-12
View File
@@ -1,12 +0,0 @@
"""Shared constants and tty helper for cli subcommands."""
from __future__ import annotations
import os
from pathlib import Path
from ..util import read_tty_line as read_tty_line
PROG = "cli.py"
USER_CWD = os.getcwd()
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
+58
View File
@@ -0,0 +1,58 @@
"""CLI subcommand registry.
One module per `bot-bottle <command>`, each exposing a `cmd_<name>(argv)`
handler. This package `__init__` maps command names to their handlers
**lazily**: a short-lived CLI run dispatches exactly one command, so
importing all twelve handlers (and their transitive deps backend,
manifest, orchestrator, ) up front is wasted work. Each COMMANDS value is
a thin wrapper that imports its handler's module on first call. Shared CLI
helpers (`constants`, `tui`) stay one level up in the `cli` package.
"""
from __future__ import annotations
from importlib import import_module
from typing import Callable
# command name -> "<submodule>:<handler attr>". Kept as strings so building
# the registry imports nothing; the module loads only when dispatched.
_HANDLERS: dict[str, str] = {
"active": "active:cmd_active",
"backend": "backend:cmd_backend",
"cleanup": "cleanup:cmd_cleanup",
"commit": "commit:cmd_commit",
"edit": "edit:cmd_edit",
"help": "help:cmd_help",
"init": "init:cmd_init",
"list": "list:cmd_list",
"login": "login:cmd_login",
"resume": "resume:cmd_resume",
"start": "start:cmd_start",
"supervise": "supervise:cmd_supervise",
}
def _lazy(spec: str) -> Callable[[list[str]], "int | None"]:
"""Wrap a `<module>:<attr>` handler so its module is imported only when
the command is actually dispatched, not when the registry is built."""
module, attr = spec.split(":")
def run(argv: list[str]) -> "int | None":
handler = getattr(import_module(f".{module}", __name__), attr)
return handler(argv)
run.__name__ = attr
return run
COMMANDS = {name: _lazy(spec) for name, spec in _HANDLERS.items()}
# Commands that manage host prerequisites (or are otherwise store-free) and
# must run before — or without — a migrated DB. `backend` provisions/probes
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
# gating it on the schema breaks preflight on a fresh CI runner where stdin
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
# likewise never touch the store.
NO_MIGRATION_COMMANDS = frozenset({"backend", "help", "login"})
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
@@ -5,8 +5,8 @@ from __future__ import annotations
import os
import sys
from ..backend import enumerate_active_agents
from ._common import PROG
from ...backend import enumerate_active_agents
from ..constants import PROG
_ANSI_COLOR_CODES: dict[str, str] = {
"red": "\033[91m",
@@ -15,8 +15,8 @@ from __future__ import annotations
import argparse
from ..backend import get_bottle_backend, known_backend_names
from ._common import PROG
from ...backend import get_bottle_backend, known_backend_names
from ..constants import PROG
def cmd_backend(args: list[str]) -> int:
@@ -21,9 +21,9 @@ from __future__ import annotations
import sys
from ..backend import get_bottle_backend, has_backend, known_backend_names
from ..log import info
from ._common import read_tty_line
from ...backend import get_bottle_backend, has_backend, known_backend_names
from ...log import info
from ...util import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int:
@@ -12,12 +12,12 @@ from __future__ import annotations
import argparse
from ..backend import enumerate_active_agents
from ..backend.freeze import CommitCancelled, get_freezer
from ..bottle_state import read_metadata
from ..log import die
from ._common import PROG
from . import tui
from ...backend import enumerate_active_agents
from ...backend.freeze import CommitCancelled, get_freezer
from ...bottle_state import read_metadata
from ...log import die
from ..constants import PROG
from .. import tui
def cmd_commit(argv: list[str]) -> int:
@@ -7,8 +7,8 @@ import json
import os
from pathlib import Path
from ..log import die
from ._common import PROG, USER_CWD
from ...log import die
from ..constants import PROG
def cmd_edit(argv: list[str]) -> int:
@@ -20,7 +20,7 @@ def cmd_edit(argv: list[str]) -> int:
if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else:
target_file = Path(USER_CWD) / "bot-bottle.json"
target_file = Path(os.getcwd()) / "bot-bottle.json"
if not target_file.is_file():
die(f"{target_file} does not exist")
+37
View File
@@ -0,0 +1,37 @@
"""help: print the top-level command list and usage.
Rendered by the dispatcher for the `help` command and for its
`-h`/`--help`, no-args, and unknown-command fallbacks. The per-command
summaries live here; keep them in sync with the COMMANDS table in
`bot_bottle.cli`.
"""
from __future__ import annotations
import sys
from ..constants import PROG
def cmd_help(argv: list[str] | None = None) -> int:
"""Write the top-level usage + command list to stderr. Returns 0;
the dispatcher chooses the process exit code per entry path (0 for an
explicit `help`/`-h`, 2 for the bare no-args usage error)."""
del argv # help takes no arguments
w = sys.stderr.write
w(f"usage: {PROG} <command> [args...]\n\n")
w("Commands:\n")
w(" active list currently-running bot-bottle bottles\n")
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
w(" cleanup stop and remove all active bot-bottle containers\n")
w(" commit snapshot a running bottle's container state to a Docker image\n")
w(" edit open an agent in vim for editing\n")
w(" help show this command list\n")
w(" init interactively create a new agent and add it to bot-bottle.json\n")
w(" list list available agents from bot-bottle.json\n")
w(" login register this host with a bot-bottle console\n")
w(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
w(" start boot a container for a named agent and attach an interactive session\n")
w(" supervise view + approve/modify/reject pending supervise proposals (PRD 0013)\n\n")
w(f"Run '{PROG} <command> --help' for command-specific usage.\n")
return 0
@@ -10,8 +10,9 @@ import sys
from pathlib import Path
from typing import Any
from ..log import die, info, warn
from ._common import PROG, USER_CWD, read_tty_line
from ...log import die, info, warn
from ..constants import PROG
from ...util import read_tty_line
def cmd_init(argv: list[str]) -> int:
@@ -22,7 +23,7 @@ def cmd_init(argv: list[str]) -> int:
if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else:
target_file = Path(USER_CWD) / "bot-bottle.json"
target_file = Path(os.getcwd()) / "bot-bottle.json"
print(file=sys.stderr)
info(f"bot-bottle init — adding a new agent to {target_file}")
@@ -2,10 +2,11 @@
from __future__ import annotations
import os
import sys
from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD
from ...manifest import ManifestIndex
from ..constants import PROG
def cmd_list(argv: list[str]) -> int:
@@ -14,7 +15,7 @@ def cmd_list(argv: list[str]) -> int:
sys.stderr.write("\nList all available agents from bot-bottle.json.\n")
return 0
manifest = ManifestIndex.resolve(USER_CWD)
manifest = ManifestIndex.resolve(os.getcwd())
for name in manifest.all_agent_names:
print(name)
return 0
@@ -25,7 +25,7 @@ import urllib.request
from pathlib import Path
from typing import Any
from ..paths import bot_bottle_root
from ...paths import bot_bottle_root
_CONSOLE_URL_ENV = "BB_CONSOLE_URL"
_POLL_SLEEP = 2 # seconds between polls; matches console's poll_interval default
@@ -15,12 +15,13 @@ to bring up the replacement from the recorded state.
from __future__ import annotations
import argparse
import os
from ..backend import BottleSpec
from ..bottle_state import read_metadata
from ..log import die
from ..manifest import ManifestIndex
from ._common import PROG, USER_CWD
from ...backend import BottleSpec
from ...bottle_state import read_metadata
from ...log import die
from ...manifest import ManifestIndex
from ..constants import PROG
from .start import _launch_bottle
@@ -40,14 +41,14 @@ def cmd_resume(argv: list[str]) -> int:
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
)
manifest = ManifestIndex.resolve(USER_CWD)
manifest = ManifestIndex.resolve(os.getcwd())
manifest.require_agent(metadata.agent_name)
spec = BottleSpec(
manifest=manifest,
agent_name=metadata.agent_name,
copy_cwd=metadata.copy_cwd,
user_cwd=metadata.cwd or USER_CWD,
user_cwd=metadata.cwd or os.getcwd(),
identity=metadata.identity,
bottle_names=tuple(metadata.bottle_names),
)
@@ -22,25 +22,26 @@ import tempfile
from pathlib import Path
from typing import Callable
from ..agent_provider import get_provider, runtime_for
from ..backend import (
from ...agent_provider import get_provider, runtime_for
from ...backend import (
Bottle,
BottleSpec,
enumerate_active_agents,
get_bottle_backend,
)
from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan
from ..bottle_state import (
from ...backend.docker import util as docker_mod
from ...backend.docker.bottle_plan import DockerBottlePlan
from ...bottle_state import (
cleanup_state,
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
from . import tui
from ...image_cache import StaleImageError
from ...log import info, die
from ...manifest import Manifest, ManifestIndex
from ..constants import PROG
from ...util import read_tty_line
from .. import tui
def cmd_start(argv: list[str]) -> int:
@@ -116,7 +117,7 @@ def cmd_start(argv: list[str]) -> int:
# threading a no_cache field through every backend's plan dataclass.
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
manifest = ManifestIndex.resolve(USER_CWD)
manifest = ManifestIndex.resolve(os.getcwd())
if args.headless:
return _start_headless(
@@ -167,7 +168,7 @@ def cmd_start(argv: list[str]) -> int:
manifest=manifest,
agent_name=agent_name,
copy_cwd=args.cwd,
user_cwd=USER_CWD,
user_cwd=os.getcwd(),
label=label,
color=color,
bottle_names=bottle_names,
@@ -235,7 +236,7 @@ def _start_headless(
manifest=manifest,
agent_name=agent_name,
copy_cwd=args.cwd,
user_cwd=USER_CWD,
user_cwd=os.getcwd(),
label=label,
color=args.color or "",
bottle_names=bottle_names,
@@ -380,8 +381,8 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
return manifest.agents[agent_name].bottle
return ""
from ..manifest_loader import scan_agent_names
from ..yaml_subset import YamlSubsetError, parse_frontmatter
from ...manifest.loader import scan_agent_names
from ...yaml_subset import YamlSubsetError, parse_frontmatter
home_agents = scan_agent_names(manifest.home_md / "agents")
cwd_agents: dict[str, Path] = {}
@@ -449,7 +450,7 @@ def _bottle_lineage(manifest: ManifestIndex) -> dict[str, str]:
if not bottles_dir.is_dir():
return {}
from ..yaml_subset import YamlSubsetError, parse_frontmatter
from ...yaml_subset import YamlSubsetError, parse_frontmatter
extends_of: dict[str, str] = {}
for path in bottles_dir.glob("*.md"):
@@ -19,22 +19,22 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from ..paths import bot_bottle_root
from ..log import Die, error, info
from ..orchestrator.client import (
from ...paths import bot_bottle_root
from ...log import Die, error, info
from ...orchestrator.client import (
OrchestratorClient,
OrchestratorClientError,
discover_orchestrator_url,
)
from ..supervise import (
from ...supervisor.types import (
Proposal,
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW,
)
from ._common import PROG
from ..constants import PROG
_REFRESH_INTERVAL_MS = 1000
@@ -81,7 +81,7 @@ def _resolve_orchestrator_url() -> str:
try:
return discover_orchestrator_url()
except OrchestratorClientError:
from ..backend import get_bottle_backend
from ...backend import get_bottle_backend
backend = get_bottle_backend()
info(f"no orchestrator control plane running; starting one ({backend.name})…")
return backend.ensure_orchestrator()
+8
View File
@@ -0,0 +1,8 @@
"""Shared CLI constants.
Kept as a leaf module (imports nothing from the `cli` package) so both the
dispatcher (`cli/__init__.py`) and the command modules it imports can share
`PROG` without a circular import.
"""
PROG = "cli.py"
+1 -1
View File
@@ -49,7 +49,7 @@ def get_provisioner(
GiteaDeployKeyProvisioner,
)
return GiteaDeployKeyProvisioner(token=token, api_url=api_url)
from .manifest_util import ManifestError
from .manifest.util import ManifestError
raise ManifestError(
f"unknown provisioned_key provider: {provider!r}; "
f"available: gitea"
-34
View File
@@ -1,34 +0,0 @@
"""Lean, framework-free `docker` subprocess primitive.
Deliberately a top-level module with a single stdlib import so it can be
reused from anywhere without cost. It is intentionally *not* placed in
`backend.docker.util`: importing that module runs `backend/__init__.py`,
which eagerly loads all three bottle backends (docker + firecracker +
macos) plus the manifest/egress/git-gate/supervise framework ~76 modules
which would drag the whole backend layer into the deliberately-lean
orchestrator. This primitive stays free of that so both the orchestrator's
docker components and (in time) `backend.docker.util` can share it."""
from __future__ import annotations
import subprocess
def run_docker(
argv: list[str], *, env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a `docker` command, capturing stdout/stderr as text. Never raises
on a non-zero exit callers inspect `returncode` / `stderr` so they can
stay fail-closed or tolerate idempotent no-ops (e.g. removing an
already-absent container).
`env` sets the child process environment used to hand a secret to a bare
`--env NAME` flag (docker inherits its value from this process) so the
value never lands on argv or in `docker inspect`'s recorded command line."""
return subprocess.run(
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
check=False, env=env,
)
__all__ = ["run_docker"]
+90
View File
@@ -0,0 +1,90 @@
"""Per-agent egress (PRD 0017).
The egress gateway is a TLS-terminating forward proxy that allow-lists a
bottle's outbound HTTP(S), scans payloads for secret exfil, and injects
per-route upstream credentials the agent never sees.
Layout:
* `service` the `Egress` host-side service (`prepare` the launch plan,
`resolve_token_values`, `agent_env_entries`) + the route-building /
rendering functions.
* `plan` `EgressPlan` / `EgressRoute`, the launch DTOs (in the backend
contract).
The runtime enforcement (the mitmproxy addon) lives in
`bot_bottle.gateway.egress.addon*`. Public names are re-exported lazily via
`__getattr__`, so `from bot_bottle.egress import ` keeps working and importing
`egress.plan` (the contract's dependency) stays light.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .plan import EgressPlan, EgressRoute
from .service import (
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
CODEX_HOST_CREDENTIAL_TOKEN_REF,
EGRESS_HOSTNAME,
EGRESS_ROUTES_FILENAME,
EGRESS_ROUTES_IN_CONTAINER,
Egress,
egress_agent_env_entries,
egress_gateway_env_entries,
egress_manifest_routes,
egress_render_routes,
egress_resolve_token_values,
egress_routes_for_bottle,
egress_token_env_map,
)
_LAZY: dict[str, str] = {
"EgressPlan": ".plan",
"EgressRoute": ".plan",
"Egress": ".service",
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF": ".service",
"CODEX_HOST_CREDENTIAL_TOKEN_REF": ".service",
"EGRESS_HOSTNAME": ".service",
"EGRESS_ROUTES_FILENAME": ".service",
"EGRESS_ROUTES_IN_CONTAINER": ".service",
"egress_agent_env_entries": ".service",
"egress_gateway_env_entries": ".service",
"egress_manifest_routes": ".service",
"egress_render_routes": ".service",
"egress_resolve_token_values": ".service",
"egress_routes_for_bottle": ".service",
"egress_token_env_map": ".service",
}
def __getattr__(name: str) -> Any:
src = _LAZY.get(name)
if src is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
value = getattr(import_module(src, __name__), name)
globals()[name] = value
return value
__all__ = [
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME",
"EGRESS_ROUTES_IN_CONTAINER",
"Egress",
"EgressPlan",
"EgressRoute",
"egress_manifest_routes",
"egress_render_routes",
"egress_resolve_token_values",
"egress_routes_for_bottle",
"egress_agent_env_entries",
"egress_gateway_env_entries",
"egress_token_env_map",
]
+48
View File
@@ -0,0 +1,48 @@
"""Egress launch DTOs (PRD 0017).
`EgressRoute` (the host-side extension of the addon's wire `Route`) and
`EgressPlan` (the launch plan the backend contract references). Pure value
types the route-building / rendering logic and the `Egress` service live in
`egress.service`.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ..gateway.egress.addon_core import Route
@dataclass(frozen=True)
class EgressRoute(Route):
"""Host-side extension of the addon's `Route`.
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
from `egress_addon_core.Route` those are the fields that cross the
YAML wire into the gateway. The fields below are host-only and
are never serialised to the addon.
`token_ref` is the host env var the CLI reads at launch and forwards
into the container's environ under `token_env`.
`roles` carries the manifest route's role tuple (reserved for
future use; always empty today)."""
token_ref: str = ""
roles: tuple[str, ...] = ()
@dataclass(frozen=True)
class EgressPlan:
slug: str
routes_path: Path
routes: tuple[EgressRoute, ...]
token_env_map: dict[str, str]
internal_network: str = ""
egress_network: str = ""
mitmproxy_ca_host_path: Path = Path()
mitmproxy_ca_cert_only_host_path: Path = Path()
log: int = 0
canary: str = ""
canary_env: str = ""
@@ -1,33 +1,33 @@
"""Per-bottle egress proxy (PRD 0017, PRD 0053).
"""The `Egress` host-side service (PRD 0017).
This module defines the abstract proxy (`Egress`), its plan
dataclass (`EgressPlan`), and the resolved per-route shape
(`EgressRoute`). The gateway's start/stop lifecycle is backend-
specific and lives on concrete subclasses (see
`bot_bottle/backend/docker/egress.py`).
`Egress` builds a bottle's egress plan at launch: resolve the manifest's egress
routes, render the gateway's `routes.yaml`, assign per-route token slots, and
plant the exfil canary. The service also resolves the launch-time token values
and the agent/gateway env entries the backend injects. The runtime enforcement
(the mitmproxy addon) lives in `bot_bottle.gateway.egress.addon*`.
"""
from __future__ import annotations
import dataclasses
import secrets
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from .egress_addon_core import (
from ..gateway.egress.addon_core import (
ON_MATCH_REDACT,
HeaderMatch as CoreHeaderMatch,
MatchEntry as CoreMatchEntry,
PathMatch as CorePathMatch,
Route,
)
from .errors import MissingEnvVarError
from .log import die
from ..errors import MissingEnvVarError
from ..log import die
from .plan import EgressPlan, EgressRoute
if TYPE_CHECKING:
from .manifest import ManifestBottle
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"
@@ -82,39 +82,6 @@ def egress_agent_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
return ()
@dataclass(frozen=True)
class EgressRoute(Route):
"""Host-side extension of the addon's `Route`.
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
from `egress_addon_core.Route` those are the fields that cross the
YAML wire into the gateway. The fields below are host-only and
are never serialised to the addon.
`token_ref` is the host env var the CLI reads at launch and forwards
into the container's environ under `token_env`.
`roles` carries the manifest route's role tuple (reserved for
future use; always empty today)."""
token_ref: str = ""
roles: tuple[str, ...] = ()
@dataclass(frozen=True)
class EgressPlan:
slug: str
routes_path: Path
routes: tuple[EgressRoute, ...]
token_env_map: dict[str, str]
internal_network: str = ""
egress_network: str = ""
mitmproxy_ca_host_path: Path = Path()
mitmproxy_ca_cert_only_host_path: Path = Path()
log: int = 0
canary: str = ""
canary_env: str = ""
def egress_manifest_routes(
bottle: ManifestBottle,
@@ -389,7 +356,11 @@ def egress_resolve_token_values(
return out
class Egress(ABC):
class Egress:
"""The host-side egress service. The backend drives `prepare` at launch,
then `resolve_token_values` to resolve the routes' upstream credentials and
`agent_env_entries` for the agent's egress env. Stateless."""
def prepare(
self,
bottle: ManifestBottle,
@@ -416,20 +387,13 @@ class Egress(ABC):
canary_env=_random_canary_env(),
)
__all__ = [
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME",
"EGRESS_ROUTES_IN_CONTAINER",
"Egress",
"EgressPlan",
"EgressRoute",
"egress_manifest_routes",
"egress_render_routes",
"egress_resolve_token_values",
"egress_routes_for_bottle",
"egress_agent_env_entries",
"egress_gateway_env_entries",
"egress_token_env_map",
]
def resolve_token_values(
self, token_env_map: dict[str, str], host_env: dict[str, str],
) -> dict[str, str]:
"""Resolve each route's upstream credential from the host env at launch.
Raises `MissingEnvVarError` for an unset/empty referenced host var."""
return egress_resolve_token_values(token_env_map, host_env)
def agent_env_entries(self, plan: EgressPlan) -> tuple[str, ...]:
"""The agent-visible egress env entries (the exfil canary)."""
return egress_agent_env_entries(plan)
+119
View File
@@ -0,0 +1,119 @@
"""The consolidated per-host gateway (PRD 0070).
The core consolidation win: **one** persistent gateway per host, shared by
every bottle, instead of a gateway per bottle. It's safe to share
because the attribution invariant (source IP + identity token, see
`registry`) lets the gateway attribute each request to the right bottle
so per-bottle policy lives in one long-lived process keyed on who's calling.
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. The docker
implementation (`DockerGateway`) lives in `backend/docker/gateway.py`; a
firecracker gateway VM slots in later.
The defining behaviour is **idempotent singleton**: `ensure_running` starts
the instance if absent and is a no-op if it's already up, so N bottle
launches never spawn N gateways.
"""
from __future__ import annotations
import abc
import os
from pathlib import Path
from ..paths import host_gateway_ca_dir
# The gateway's mitmproxy writes its CA a beat after the container starts, so
# reads poll for it rather than assuming it's there on a fresh launch.
CA_POLL_SECONDS = 0.5
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
GATEWAY_NAME = "bot-bottle-orch-gateway"
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# The single user-defined network the gateway and every agent bottle share.
# Agents attach here with a pinned IP and reach the gateway's egress /
# git-http / supervise ports by its address — no host port publishing, and
# the source IP the gateway attributes by is the address on this network.
GATEWAY_NETWORK = "bot-bottle-gateway"
# mitmproxy's CA dir in the bundle. The host's gateway-CA dir (see
# `host_gateway_ca_dir`) is bind-mounted here so the gateway's self-generated
# CA stays STABLE across container recreation — every agent installs this one
# CA to trust the shared gateway's TLS interception, so it must not rotate when
# the gateway restarts. A host bind-mount rather than a named volume: a named
# volume is silently wiped by `docker volume prune`, minting a fresh CA that
# breaks every running bottle (issue #450).
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
# The CA material mitmproxy writes into its confdir. mitmproxy reuses these on
# startup when present and generates them only on first run, so persisting them
# is what makes the CA stable; deleting them (see `rotate_gateway_ca`) forces a
# fresh CA on the next start. `mitmproxy-ca.pem` (cert + private key) is the
# signing identity; the rest are derived encodings agents/clients consume.
GATEWAY_CA_GLOB = "mitmproxy-ca*"
# The gateway data-plane image + its Dockerfile. Kept as a local constant
# rather than imported from the backend layer, which would drag
# the whole backend layer into the lean orchestrator (see #359); unify when
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
REPO_ROOT = Path(__file__).resolve().parents[2]
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
"""Delete the persisted mitmproxy CA so the next gateway start mints a
fresh one the explicit, deliberate CA-rollover path (issue #450).
Persistence keeps the CA stable across restarts precisely because mitmproxy
reuses the on-disk CA; rotation is therefore just removing that material.
Returns the files removed (empty when there was no CA yet); idempotent.
This only clears the on-disk CA. It does NOT stop the running gateway (whose
mitmproxy still holds the old CA in memory) or re-provision agents the
caller recreates the gateway to mint the new CA and re-attaches bottles.
`rotate-ca` on the orchestrator CLI wires those steps together."""
ca_dir = ca_dir if ca_dir is not None else host_gateway_ca_dir()
removed: list[Path] = []
for path in sorted(ca_dir.glob(GATEWAY_CA_GLOB)):
path.unlink()
removed.append(path)
return removed
class GatewayError(Exception):
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
class Gateway(abc.ABC):
"""Lifecycle of the single per-host gateway. Backend-neutral."""
name: str
def ensure_built(self) -> None:
"""Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
return
@abc.abstractmethod
def ensure_running(self) -> None:
"""Start the gateway if it isn't already up. Idempotent: a no-op
when it's already running (that's the whole point one per host).
Assumes the image exists call `ensure_built()` first."""
@abc.abstractmethod
def is_running(self) -> bool:
"""True iff the gateway instance is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the gateway. Idempotent — absent is success."""
__all__ = [
"Gateway", "GatewayError", "rotate_gateway_ca",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
]
@@ -54,6 +54,15 @@ class _DaemonSpec:
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
_READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
# The control-plane signing key is the orchestrator's alone — verifying tokens.
# The data-plane daemons instead hold the pre-minted `gateway` JWT they present.
# Scoping each to its process (even in the combined infra container) keeps a
# compromised data-plane daemon from reading the key and minting a `cli` token
# (issue #469 review). Values match paths.ORCHESTRATOR_TOKEN_ENV /
# ORCHESTRATOR_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light.
_SIGNING_KEY_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
_GATEWAY_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# Daemons that must be requested explicitly via BOT_BOTTLE_GATEWAY_DAEMONS
# and are NOT started in the default (env-var-unset) case. The orchestrator
# only runs in the combined infra container, never in a standalone gateway.
@@ -61,16 +70,24 @@ _OPT_IN_DAEMONS: frozenset[str] = frozenset({"orchestrator"})
def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
"""Egress sees the full bundle env. Everyone else gets a copy
with `EGRESS_TOKEN_*` (and any other future egress-only
credential slots) stripped. Returns a fresh dict callers
can mutate without affecting `base_env`."""
if name == "egress":
return dict(base_env)
return {
k: v for k, v in base_env.items()
if not any(k.startswith(p) for p in _EGRESS_ONLY_ENV_PREFIXES)
}
"""Per-daemon env, scoped to what each process legitimately needs.
Returns a fresh dict callers can mutate without affecting `base_env`.
* `EGRESS_TOKEN_*` upstream-auth slots go to egress only.
* the control-plane signing key goes to the orchestrator only.
* the pre-minted `gateway` JWT goes to the data-plane daemons only (the
orchestrator verifies tokens, it never presents one)."""
env = dict(base_env)
if name != "egress":
env = {
k: v for k, v in env.items()
if not any(k.startswith(p) for p in _EGRESS_ONLY_ENV_PREFIXES)
}
if name == "orchestrator":
env.pop(_GATEWAY_JWT_ENV, None)
else:
env.pop(_SIGNING_KEY_ENV, None)
return env
# The orchestrator is listed first so it starts before the gateway daemons,
@@ -83,8 +100,8 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
)),
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.git_http_backend")),
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.supervise_server")),
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.gateway.git_gate.http_backend")),
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.gateway.supervisor.server")),
)
@@ -156,7 +173,7 @@ def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
return proc
class _Supervisor:
class _DaemonManager:
"""Holds the running children + shutdown state. Pulled out so
the test suite can drive it with fake commands."""
@@ -370,7 +387,7 @@ def main(argv: Sequence[str] | None = None) -> int:
_log("no daemons selected; nothing to do")
return 0
sup = _Supervisor(specs)
sup = _DaemonManager(specs)
sup.start_all()
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM")) # type: ignore
+9
View File
@@ -0,0 +1,9 @@
"""Gateway-side (data-plane) egress service: the mitmproxy addon and its
pure decision core, DLP detectors, and route/DLP config parsing.
These are the long-running data-plane pieces (loaded by mitmdump inside the
`bot-bottle-gateway` image), distinct from the host-side `bot_bottle.egress`
service that renders routes and prepares env. Import the concrete modules
directly (`from bot_bottle.gateway.egress.addon_core import ...`) this
package deliberately does no eager work so leaf imports stay cheap.
"""
@@ -16,8 +16,8 @@ import typing
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.dlp_detectors import redact_tokens, strip_crlf
from bot_bottle.egress_addon_core import (
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens, strip_crlf
from bot_bottle.gateway.egress.addon_core import (
LOG_BLOCKS,
LOG_FULL,
DEFAULT_OUTBOUND_ON_MATCH,
@@ -40,8 +40,13 @@ from bot_bottle.egress_addon_core import (
scan_inbound,
scan_outbound,
)
from bot_bottle import supervise as _sv
from bot_bottle.policy_resolver import PolicyResolver
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.supervisor.types import (
STATUS_APPROVED,
STATUS_MODIFIED,
STATUSES,
TOOL_EGRESS_TOKEN_ALLOW,
)
INTROSPECT_HOST = "_egress.local"
@@ -314,8 +319,15 @@ class EgressAddon:
flow.request.headers.pop("Proxy-Authorization", None)
flow.request.headers.pop(IDENTITY_HEADER, None)
conn = flow.client_conn
if not token and conn is not None:
token = self._conn_tokens.get(getattr(conn, "id", ""), "")
conn_id = getattr(conn, "id", "") if conn is not None else ""
if not token and conn_id:
token = self._conn_tokens.get(conn_id, "")
# Remember the token per connection so a later token-block on this flow
# can attribute its supervise proposal by (source_ip, identity_token)
# over RPC — plain-HTTP requests carry it here, HTTPS tunnels captured
# it at CONNECT (http_connect); both land in `_conn_tokens`.
if token and conn_id:
self._conn_tokens[conn_id] = token
return token
def http_connect(self, flow: http.HTTPFlow) -> None:
@@ -592,42 +604,48 @@ class EgressAddon:
redact_tokens(request_path, env=env),
result,
)
proposal = _sv.Proposal.new(
bottle_slug=slug,
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
proposed_file=payload,
justification=_TOKEN_ALLOW_JUSTIFICATION,
current_file_hash=_sv.sha256_hex(payload),
)
# Attribute the proposal by (source_ip, identity_token) over the control
# plane — the data plane no longer opens bot-bottle.db (PRD 0070 / #469).
# source_ip + token come from this bottle's connection; `slug` is kept
# only for its own DLP safelist.
conn = flow.client_conn
source_ip = conn.peername[0] if conn is not None and conn.peername else ""
token = self._conn_tokens.get(getattr(conn, "id", ""), "") if conn is not None else ""
try:
_sv.write_proposal(proposal)
except OSError as e:
proposal_id = self._resolver.propose_supervise(
source_ip, token,
tool=TOOL_EGRESS_TOKEN_ALLOW,
proposed_file=payload,
justification=_TOKEN_ALLOW_JUSTIFICATION,
)
except PolicyResolveError as e:
sys.stderr.write(
f"egress: could not queue token-allow proposal: {e}; "
"blocking request\n"
)
proposal_id = None
if proposal_id is None:
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
return False
sys.stderr.write(json.dumps({
"event": "egress_token_supervise",
"reason": f"egress DLP: {result.reason}",
"proposal": proposal.id,
"proposal": proposal_id,
**self._req_ctx(flow),
}) + "\n")
response = await self._await_token_response(proposal.id, slug)
_sv.archive_proposal(slug, proposal.id)
response = await self._await_token_response(proposal_id, source_ip, token)
if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
if response is not None and response.get("status") in (
STATUS_APPROVED, STATUS_MODIFIED,
):
self._safe_tokens_for(slug).add(result.matched)
if self._flow_log(flow) >= LOG_BLOCKS:
sys.stderr.write(json.dumps({
"event": "egress_token_allowed",
"reason": f"egress DLP: {result.reason}",
"proposal": proposal.id,
"proposal": proposal_id,
**self._req_ctx(flow),
}) + "\n")
return True
@@ -645,19 +663,23 @@ class EgressAddon:
async def _await_token_response(
self,
proposal_id: str,
slug: str,
) -> "_sv.Response | None":
"""Poll the DB for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout."""
source_ip: str,
token: str,
) -> "dict[str, object] | None":
"""Poll the control plane for the operator's decision without blocking
the proxy event loop. Returns the terminal `{status, ...}` payload once
decided, or None on timeout. A transient orchestrator error (or a
`pending`/`unknown` status) is retried until the deadline, then fails
closed the caller blocks the request."""
loop = asyncio.get_running_loop()
deadline = loop.time() + self._token_allow_timeout
while True:
try:
return _sv.read_response(slug, proposal_id)
except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed.
pass
result = self._resolver.poll_supervise(source_ip, token, proposal_id)
except PolicyResolveError:
result = None
if result is not None and result.get("status") in STATUSES:
return result
if loop.time() >= deadline:
return None
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
@@ -16,11 +16,11 @@ import re
import typing
from dataclasses import dataclass
from .yaml_subset import YamlSubsetError, parse_yaml_subset
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
# DLP detector-config parsing lives in a sibling module. Re-exported below
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working.
from .egress_dlp_config import (
from .dlp_config import (
DEFAULT_OUTBOUND_ON_MATCH,
INBOUND_DETECTOR_NAMES,
ON_MATCH_BLOCK,
@@ -19,7 +19,7 @@ from math import log2
from collections import Counter
from urllib.parse import quote as url_quote
from .egress_addon_core import ScanResult
from .addon_core import ScanResult
# ---------------------------------------------------------------------------
+7
View File
@@ -0,0 +1,7 @@
"""Gateway-side (data-plane) git-gate service: pure host-side hook rendering
(PRD 0008) and the smart-HTTP backend that fronts git-gate repos.
The host-side git-gate service (provisioning, preflight) lives in
`bot_bottle.git_gate`; this is the data-plane counterpart. Import the concrete
modules directly (`from bot_bottle.gateway.git_gate.render import ...`).
"""
@@ -27,7 +27,7 @@ from pathlib import Path
from urllib.parse import urlsplit
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
DEFAULT_PORT = 9420
@@ -167,11 +167,14 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"SERVER_PORT": str(self.server.server_port), # type: ignore
"SERVER_PROTOCOL": self.request_version,
})
# Attribute the gitleaks-allow supervise proposal (written by
# Attribute the gitleaks-allow supervise proposal (queued by
# receive-pack's pre-receive hook, a child of the CGI we spawn below) to
# the calling bottle. The namespaced root is `<base>/<bottle_id>`, so its
# final component is the bottle id — the same per-bottle key egress uses.
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
# the calling bottle. The hook queues over the control-plane RPC (PRD
# 0070 / issue #469), which re-resolves the bottle from these — the same
# (source_ip, identity_token) pair that selected the namespaced root
# above — so it can only ever queue its own proposals.
env["SUPERVISE_SOURCE_IP"] = self.client_address[0]
env["SUPERVISE_IDENTITY_TOKEN"] = self.headers.get(IDENTITY_HEADER, "")
for header, variable in (
("accept", "HTTP_ACCEPT"),
("content-encoding", "HTTP_CONTENT_ENCODING"),
@@ -3,9 +3,9 @@
Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts
line, and the entrypoint / pre-receive / access-hook scripts the gateway
runs. No docker or forge calls exposed for tests and reuse across
backends. Split out of `git_gate.py` so the control surface (`GitGate`)
and the deploy-key lifecycle (`git_gate_provision`) each read on their
own; `git_gate` re-exports these names for API stability."""
backends. The host-side service (`git_gate.GitGate`) and the deploy-key
lifecycle (`git_gate.provision`) each read on their own; `git_gate`
re-exports these names for API stability."""
from __future__ import annotations
@@ -14,8 +14,8 @@ import shlex
from dataclasses import dataclass
from pathlib import Path
from .constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from .manifest import ManifestBottle, ManifestGitEntry
from ...constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from ...manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the gateway. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
@@ -272,21 +272,27 @@ supervise_gitleaks_allow() {
proposal_id=$(
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
import datetime
import hashlib
import json
import os
import sys
from pathlib import Path
# Queue over the control-plane RPC — the git-gate data plane no longer opens
# bot-bottle.db (PRD 0070 / issue #469). Attribution is by (source_ip,
# identity_token), resolved server-side, so the proposal lands under the
# calling bottle exactly as a direct write once did.
try:
import supervise as _sv
from bot_bottle.gateway.policy_resolver import PolicyResolver, PolicyResolveError
from bot_bottle.supervisor.types import TOOL_GITLEAKS_ALLOW
except ImportError:
from bot_bottle import supervise as _sv
from policy_resolver import PolicyResolver, PolicyResolveError
from supervisor.types import TOOL_GITLEAKS_ALLOW
report_path = Path(sys.argv[1])
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not slug:
source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "")
token = os.environ.get("SUPERVISE_IDENTITY_TOKEN", "")
orch_url = os.environ.get("BOT_BOTTLE_ORCHESTRATOR_URL", "")
if not source_ip or not orch_url:
sys.exit(2)
try:
@@ -323,19 +329,21 @@ 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=(
"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)
try:
proposal_id = PolicyResolver(orch_url).propose_supervise(
source_ip, token,
tool=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"
),
)
except PolicyResolveError:
sys.exit(4)
if not proposal_id:
sys.exit(2)
print(proposal_id)
PY
)
rc=$?
@@ -348,7 +356,6 @@ PY
return 1
fi
slug=${SUPERVISE_BOTTLE_SLUG:-}
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
case "$timeout" in
''|*[!0-9]*)
@@ -360,20 +367,30 @@ 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'
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY'
import os
import sys
# Non-blocking poll over the control plane. A decided proposal is archived
# server-side on read, so no separate archive step is needed here.
try:
import supervise as _sv
from bot_bottle.gateway.policy_resolver import PolicyResolver, PolicyResolveError
except ImportError:
from bot_bottle import supervise as _sv
from policy_resolver import PolicyResolver, PolicyResolveError
slug = sys.argv[1]
source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "")
token = os.environ.get("SUPERVISE_IDENTITY_TOKEN", "")
orch_url = os.environ.get("BOT_BOTTLE_ORCHESTRATOR_URL", "")
try:
response = _sv.read_response(slug, sys.argv[2])
except FileNotFoundError:
result = PolicyResolver(orch_url).poll_supervise(source_ip, token, sys.argv[1])
except PolicyResolveError:
sys.exit(2)
print(response.status)
if result is None:
sys.exit(2)
status = result.get("status", "")
if status in ("pending", "unknown"):
sys.exit(2)
print(status)
PY
)
rc=$?
@@ -385,16 +402,6 @@ PY
if [ -n "$status" ]; then
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
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
return 0
;;
@@ -34,22 +34,23 @@ import urllib.request
DEFAULT_TIMEOUT_SECONDS = 2.0
# The control-plane secret this gateway presents on every /resolve call, read
# from the env the launcher injects into the gateway container. The control
# plane requires it (orchestrator/control_plane.py). Constant + env-var name are
# duplicated here rather than imported because this module is COPYed flat into
# the gateway image, free of bot-bottle imports — same rationale as
# IDENTITY_HEADER in egress_addon / git_http_backend.
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
# The role-scoped `gateway` token this data plane presents on every control-plane
# call, read from the env the launcher injects into the gateway (a pre-minted
# signed JWT — the gateway never holds the signing key, so it can't forge a
# higher-privilege `cli` token). Constant + env-var name are duplicated here
# rather than imported because this module is COPYed flat into the gateway image,
# free of bot-bottle imports — same rationale as IDENTITY_HEADER in egress_addon
# / git_http_backend.
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
def _control_auth_headers() -> dict[str, str]:
"""The auth header to send, or {} when no secret is configured (an open
def _orchestrator_auth_headers() -> dict[str, str]:
"""The auth header to send, or {} when no token is configured (an open
control plane, e.g. Firecracker behind its nft boundary sending nothing
is correct there and harmlessly ignored)."""
token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
return {CONTROL_AUTH_HEADER: token} if token else {}
token = os.environ.get(ORCHESTRATOR_AUTH_JWT_ENV, "").strip()
return {ORCHESTRATOR_AUTH_HEADER: token} if token else {}
class PolicyResolveError(RuntimeError):
@@ -64,28 +65,80 @@ class PolicyResolver:
self._base = base_url.rstrip("/")
self._timeout = timeout
def _post_json(self, path: str, payload: dict[str, object]) -> dict[str, object] | None:
"""POST `payload` to control-plane `path`, returning the JSON object body
or None when the orchestrator answers `403` (unattributed / fail
closed). Raises `PolicyResolveError` on an unreachable / unexpected-status
/ malformed response so every caller can fail closed. Shared by
`_post_resolve` and the supervise propose/poll RPCs."""
body = json.dumps(payload).encode()
req = urllib.request.Request(
f"{self._base}{path}", data=body, method="POST",
headers={"Content-Type": "application/json", **_orchestrator_auth_headers()},
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 403:
return None # unattributed → fail closed (caller denies)
raise PolicyResolveError(f"{path} returned HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise PolicyResolveError(f"{path} unreachable or malformed: {e}") from e
return data if isinstance(data, dict) else {}
def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
"""The orchestrator's `/resolve` payload for this client, or None if
unattributed (a clean `403`). Raises `PolicyResolveError` on an
unreachable / unexpected-status / malformed response so every caller
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
body = json.dumps(
{"source_ip": source_ip, "identity_token": identity_token}
).encode()
req = urllib.request.Request(
f"{self._base}/resolve", data=body, method="POST",
headers={"Content-Type": "application/json", **_control_auth_headers()},
return self._post_json(
"/resolve", {"source_ip": source_ip, "identity_token": identity_token},
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
payload = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 403:
return None # unattributed → fail closed (caller denies)
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
return payload if isinstance(payload, dict) else {}
def propose_supervise(
self,
source_ip: str,
identity_token: str,
*,
tool: str,
proposed_file: str,
justification: str,
) -> str | None:
"""Queue a supervise proposal on the control plane and return its
`proposal_id`. The orchestrator attributes the proposal to the calling
bottle by `(source_ip, identity_token)` exactly like `/resolve` so a
bottle can only ever queue its *own* proposals. Returns None when the
pair is unattributed (a clean `403`); raises `PolicyResolveError` if the
orchestrator can't be reached, so the data-plane caller fails closed
(blocks / refuses) rather than silently dropping the proposal."""
payload = self._post_json("/supervise/propose", {
"source_ip": source_ip,
"identity_token": identity_token,
"tool": tool,
"proposed_file": proposed_file,
"justification": justification,
})
if payload is None:
return None
proposal_id = payload.get("proposal_id")
return proposal_id if isinstance(proposal_id, str) and proposal_id else None
def poll_supervise(
self, source_ip: str, identity_token: str, proposal_id: str,
) -> dict[str, object] | None:
"""Poll a queued proposal for the operator's decision, **non-blocking**.
Attributed by `(source_ip, identity_token)` so a bottle can only read its
*own* proposal's response. Returns `{"status": ...}` where status is one
of the terminal decisions (`approved`/`modified`/`rejected`, carrying
`notes` + `final_file`), `pending` (queued, no decision yet), or
`unknown` (no such queued proposal for this bottle). Returns None when
unattributed; raises `PolicyResolveError` if unreachable."""
return self._post_json("/supervise/poll", {
"source_ip": source_ip,
"identity_token": identity_token,
"proposal_id": proposal_id,
})
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
"""The calling bottle's policy blob, or None if unattributed. Always
@@ -0,0 +1,7 @@
"""Gateway-side (data-plane) supervise service: the supervise daemon's HTTP
server (PRD 0013).
The host-side supervise control lives in `bot_bottle.orchestrator.supervisor`;
this is the in-gateway daemon the agents reach. Import the concrete module
directly (`from bot_bottle.gateway.supervisor.server import ...`).
"""
@@ -7,9 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
Each queued proposal 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, up to a short grace
window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`, default 30s).
2. Queues a Proposal over the control-plane RPC (`POST /supervise/propose`),
which attributes it to the calling bottle by (source_ip, identity_token)
and writes it to the one orchestrator-owned SQLite database. This daemon
never opens `bot-bottle.db` itself (PRD 0070 / issue #469).
3. Polls the control plane (`POST /supervise/poll`) for a matching Response,
up to a short grace window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`,
default 30s).
4. On a decision within the window, returns the operator's
`{status, notes}`. On timeout, returns `status: pending` **with the
proposal id** and leaves the proposal queued the flow is
@@ -24,8 +28,8 @@ without holding an HTTP request open.
One shared server fronts every bottle (PRD 0070) and attributes each
proposal to the calling bottle by source IP, resolved from the orchestrator
an unattributed or unreachable source fails closed. BOT_BOTTLE_ORCHESTRATOR_URL
is mandatory: there is no fixed-slug single-tenant fallback. SUPERVISE_DB_PATH
points at the bind-mounted host database.
is mandatory: there is no fixed-slug single-tenant fallback, and the queue is
reached only over that control-plane RPC (no direct database handle).
Speaks MCP over HTTP+JSON-RPC. Methods handled:
@@ -51,14 +55,14 @@ import socketserver
import sys
import time
import typing
from dataclasses import dataclass, replace
from dataclasses import dataclass
from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.egress_addon_core import (
from bot_bottle.gateway.egress.addon_core import (
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
)
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle import supervise as _sv
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.supervisor import types as _sv
# --- JSON-RPC / MCP plumbing ----------------------------------------------
@@ -78,7 +82,11 @@ ERR_INVALID_PARAMS = -32602
ERR_INTERNAL = -32603
DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05
# Grace-window poll cadence. 250 ms keeps the operator-visible latency low while
# staying gentle on the shared control plane — each poll is now an HTTP round
# trip, not a local file read, so the old 50 ms would be ~20 req/s per pending
# proposal across every waiting agent (issue #469 review).
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.25
# The per-host orchestrator control plane the shared supervise server attributes
# each proposal to, by source IP. Mandatory — there is no single-tenant
@@ -312,7 +320,9 @@ def validate_proposed_file(tool: str, content: str) -> None:
@dataclass(frozen=True)
class ServerConfig:
bottle_slug: str
# No bottle_slug: the calling bottle is attributed per request by the
# control plane from (source_ip, identity_token), so nothing on this daemon
# is keyed by a single slug (PRD 0070 / issue #469).
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
@@ -331,14 +341,22 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
def handle_tools_call(
params: dict[str, object],
config: ServerConfig,
*,
resolver: "PolicyResolver",
source_ip: str,
identity_token: str,
) -> dict[str, object]:
"""Validates the proposal, writes it to the queue, blocks waiting
for a Response, returns the result wrapped in MCP `content`.
"""Validates the proposal, queues it over the control-plane RPC, polls for
a Response through the grace window, and returns the result wrapped in MCP
`content`.
`list-egress-routes` never reaches here the handler answers it from
the calling bottle's resolved policy before dispatching (see
`MCPHandler._dispatch`); this path is the queued, operator-approved
`egress-allow` / `egress-block` tools."""
The queue lives behind the orchestrator now `propose_supervise` attributes
the proposal to the calling bottle by `(source_ip, identity_token)` and
`poll_supervise` reads only that bottle's own responses, so this daemon
never opens `bot-bottle.db`. `list-egress-routes` never reaches here the
handler answers it from the calling bottle's resolved policy before
dispatching (see `MCPHandler._dispatch`); this path is the queued,
operator-approved `egress-allow` / `egress-block` tools."""
name = params.get("name")
if not isinstance(name, str):
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
@@ -366,59 +384,100 @@ def handle_tools_call(
else:
raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
proposal = _sv.Proposal.new(
bottle_slug=config.bottle_slug,
tool=name,
proposed_file=proposed_file,
justification=justification,
current_file_hash=_sv.sha256_hex(proposed_file),
proposal_id = _queue_proposal(
resolver, source_ip, identity_token,
tool=name, proposed_file=proposed_file, justification=justification,
)
try:
_sv.write_proposal(proposal)
except OSError as e:
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
sys.stderr.write(
f"supervise: queued proposal {proposal.id} ({name}) "
f"for bottle {config.bottle_slug}; waiting for operator...\n"
f"supervise: queued proposal {proposal_id} ({name}); waiting for operator...\n"
)
sys.stderr.flush()
deadline = time.monotonic() + config.response_timeout_seconds
try:
response = _sv.wait_for_response(
config.bottle_slug,
proposal.id,
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
deadline=deadline,
)
except TimeoutError:
text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
return {
"content": [{"type": "text", "text": text}],
"isError": False,
}
try:
_sv.archive_proposal(config.bottle_slug, proposal.id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
text = format_response_text(response)
deadline = time.monotonic() + config.response_timeout_seconds
while time.monotonic() < deadline:
response = _poll_response(resolver, source_ip, identity_token, proposal_id)
if response is not None:
return {
"content": [{"type": "text", "text": format_response_text(response)}],
"isError": response.status == _sv.STATUS_REJECTED,
}
time.sleep(MIN_RESPONSE_POLL_INTERVAL_SECONDS)
text = format_pending_response_text(proposal_id, config.response_timeout_seconds)
return {
"content": [{"type": "text", "text": text}],
"isError": response.status == _sv.STATUS_REJECTED,
"isError": False,
}
def _queue_proposal(
resolver: "PolicyResolver",
source_ip: str,
identity_token: str,
*,
tool: str,
proposed_file: str,
justification: str,
) -> str:
"""Queue a proposal over the control plane and return its id, mapping the
resolver's fail-closed outcomes to internal errors so `do_POST` returns a
clean -32603 rather than leaking the cause to the agent."""
try:
proposal_id = resolver.propose_supervise(
source_ip, identity_token,
tool=tool, proposed_file=proposed_file, justification=justification,
)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot queue proposal: {e}") from e
if proposal_id is None:
raise _RpcInternalError("request source is not attributed to a bottle")
return proposal_id
def _poll_response(
resolver: "PolicyResolver", source_ip: str, identity_token: str, proposal_id: str,
) -> "_sv.Response | None":
"""One non-blocking control-plane poll for `proposal_id`'s decision. Returns
the operator's Response once decided, or None while it is still pending
(or `unknown` treated as still-waiting so a transient blip degrades to the
pending-timeout path rather than a spurious error mid grace window)."""
try:
result = resolver.poll_supervise(source_ip, identity_token, proposal_id)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot poll proposal: {e}") from e
if result is None:
raise _RpcInternalError("request source is not attributed to a bottle")
if result.get("status") in _sv.STATUSES:
return _response_from_poll(proposal_id, result)
return None
def _response_from_poll(proposal_id: str, result: "dict[str, object]") -> "_sv.Response":
"""Build a Response from a decided `/supervise/poll` payload."""
final_file = result.get("final_file")
return _sv.Response(
proposal_id=proposal_id,
status=str(result.get("status")),
notes=str(result.get("notes") or ""),
final_file=final_file if isinstance(final_file, str) else None,
)
def handle_check_proposal(
params: dict[str, object],
config: ServerConfig,
*,
resolver: "PolicyResolver",
source_ip: str,
identity_token: str,
) -> dict[str, object]:
"""Non-blocking poll of a queued proposal's decision, by id.
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
reads the queue. Resolution order mirrors the synchronous path's terminal
step a decided proposal is archived here exactly as `handle_tools_call`
archives it after `wait_for_response`, so `pending` proposals stay visible
to the operator until they're both decided *and* polled."""
reads the queue over the control-plane RPC, attributed by
`(source_ip, identity_token)` so it can only read *this* bottle's own
proposals. The orchestrator archives a decided proposal on read the same
terminal step the synchronous path triggers so `pending` proposals stay
visible to the operator until they're both decided *and* polled."""
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
@@ -431,25 +490,24 @@ def handle_check_proposal(
proposal_id = proposal_id.strip()
try:
response = _sv.read_response(config.bottle_slug, proposal_id)
except FileNotFoundError:
# No decision yet — distinguish "still queued" from "unknown id".
try:
_sv.read_proposal(config.bottle_slug, proposal_id)
except FileNotFoundError:
return {
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
"isError": True,
}
result = resolver.poll_supervise(source_ip, identity_token, proposal_id)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot poll proposal: {e}") from e
if result is None:
raise _RpcInternalError("request source is not attributed to a bottle")
status = result.get("status")
if status == _sv.POLL_STATUS_UNKNOWN:
return {
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
"isError": True,
}
if status == _sv.POLL_STATUS_PENDING:
return {
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
"isError": False,
}
try:
_sv.archive_proposal(config.bottle_slug, proposal_id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
response = _response_from_poll(proposal_id, result)
return {
"content": [{"type": "text", "text": format_response_text(response)}],
"isError": response.status == _sv.STATUS_REJECTED,
@@ -591,16 +649,34 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
# — silently dropping base routes like api.anthropic.com on approval.
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
return self._resolved_routes_payload()
resolver = self._resolver_or_fail()
source_ip = self.client_address[0]
token = self._identity_token()
# `check-proposal` is a non-blocking read of the calling bottle's
# own queue — attributed by source IP like a proposal, but it
# never queues or blocks.
# own queue — attributed by (source_ip, identity_token) like a
# proposal, but it never queues or blocks.
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
return handle_check_proposal(req.params, self._attributed_config(config))
# Attribute the proposal to the source-IP-resolved bottle, so the one
# shared server queues each bottle's proposal under its own slug.
return handle_tools_call(req.params, self._attributed_config(config))
return handle_check_proposal(
req.params, resolver=resolver,
source_ip=source_ip, identity_token=token,
)
# The control plane attributes the proposal to the source-IP + token
# resolved bottle, so the one shared queue holds each bottle's
# proposal under its own id — no slug is asserted by this daemon.
return handle_tools_call(
req.params, config, resolver=resolver,
source_ip=source_ip, identity_token=token,
)
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
def _identity_token(self) -> str:
"""The agent's per-bottle identity token from the request header (the
MCP client sends it via `mcp add --header`), or empty. The control plane
requires the (source_ip, token) pair, so a missing/wrong token
fail-closes there."""
headers = getattr(self, "headers", None)
return headers.get(IDENTITY_HEADER, "") if headers is not None else ""
def _resolver_or_fail(self) -> "PolicyResolver":
"""This server's policy resolver. A server started without one is a
misconfiguration, not a tenancy mode fail closed rather than
@@ -612,40 +688,18 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
def _resolved_routes_payload(self) -> dict[str, object]:
"""The calling bottle's live egress routes as the `list-egress-routes`
JSON payload, resolved by (source_ip, identity token). Fail-closed like
`_attributed_config`: an unattributed source or an unreachable
orchestrator yields an empty route list (never another bottle's),
courtesy of `resolve_client_context`."""
JSON payload, resolved by (source_ip, identity token). Fail-closed: an
unattributed source or an unreachable orchestrator yields an empty route
list (never another bottle's), courtesy of `resolve_client_context`."""
resolver = self._resolver_or_fail()
headers = getattr(self, "headers", None)
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
conf, _slug, _tokens = resolve_client_context(
resolver, self.client_address[0], token,
resolver, self.client_address[0], self._identity_token(),
)
body = json.dumps(
{"routes": [route_to_yaml_dict(r) for r in conf.routes]}, indent=2,
)
return {"content": [{"type": "text", "text": body}], "isError": False}
def _attributed_config(self, config: ServerConfig) -> ServerConfig:
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle:
the bottle id attributed from the source IP **fail-closed**, an
unattributed or unreachable source raises so no proposal is queued under
the wrong (or empty) slug."""
resolver = self._resolver_or_fail()
# The agent's MCP client sends the identity token as a request header
# (provisioned via `mcp add --header`); the orchestrator requires the
# (source_ip, token) pair, so a missing/wrong token fail-closes below.
headers = getattr(self, "headers", None)
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
try:
bottle_id = resolver.resolve_bottle_id(self.client_address[0], token)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
if not bottle_id:
raise _RpcInternalError("request source is not attributed to a bottle")
return replace(config, bottle_slug=bottle_id)
def _write_jsonrpc(self, body: bytes) -> None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
@@ -668,10 +722,10 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="")
# Set by `serve`; every proposal is attributed to the source-IP-resolved
# bottle. The class default is a placeholder — a server without a resolver
# fails closed per request (see `_resolver_or_fail`).
config: ServerConfig = ServerConfig()
# Set by `serve`; every proposal is attributed to the source-IP + token
# resolved bottle by the control plane. A server without a resolver fails
# closed per request (see `_resolver_or_fail`).
policy_resolver: "PolicyResolver | None" = None
@@ -686,12 +740,9 @@ def serve(
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
) -> typing.NoReturn:
server = MCPServer((bind, port), MCPHandler)
# bottle_slug is a placeholder: every request's proposal is attributed to
# the source-IP-resolved bottle (see MCPHandler._attributed_config).
server.config = ServerConfig(
bottle_slug="",
response_timeout_seconds=response_timeout_seconds,
)
# Every request's proposal is attributed to the source-IP + token resolved
# bottle by the control plane (see MCPHandler._dispatch).
server.config = ServerConfig(response_timeout_seconds=response_timeout_seconds)
server.policy_resolver = resolver
sys.stderr.write(
f"supervise listening on {bind}:{port}; multi-tenant; "
-169
View File
@@ -1,169 +0,0 @@
"""Per-agent git-gate (PRD 0008).
A third per-agent daemon that fronts the bottle's declared git
upstreams as a transparent mirror. Each `bottle.git` entry maps to
a bare repo on the gate; `git daemon` serves the bare repos over
`git://<gate>/<name>.git`. Two hooks make the mirror bidirectional:
- **`pre-receive`** (push path) gitleaks-scans incoming refs and,
on clean, forwards them to the real upstream with the
gate-resident credential.
- **`--access-hook`** (fetch path) runs `git fetch origin --prune`
against the real upstream before every `upload-pack`, so an
agent fetch returns whatever the upstream has *now*. Fail-closed
if the upstream is unreachable.
The agent never sees the upstream credential under either path.
Why a separate daemon (not folded into egress or ssh-gate): the
gate is the only one of the three that holds upstream push
credentials. Mixing it with egress would put push creds in the
same blast radius as internet-facing TLS interception; mixing it
with ssh-gate would force ssh-gate above L4 and into git-protocol
land. See `docs/prds/0008-git-gate.md`.
This module defines the abstract gate (`GitGate`) and its plan
dataclass (`GitGatePlan`). The gateway's start/stop lifecycle is
backend-specific and lives on concrete subclasses (see
`bot_bottle/backend/docker/git_gate.py`)."""
from __future__ import annotations
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from .manifest import ManifestBottle
# Rendering and the deploy-key lifecycle live in sibling modules; the
# names are re-exported here (see __all__) so existing
# `from bot_bottle.git_gate import …` callers are unchanged.
from .git_gate_render import (
GIT_GATE_HOSTNAME,
GIT_GATE_TIMEOUT_SECS,
GitGateUpstream,
git_gate_known_hosts_line,
git_gate_render_access_hook,
git_gate_render_entrypoint,
git_gate_render_provision,
git_gate_render_gitconfig,
git_gate_render_hook,
git_gate_upstreams_for_bottle,
_gitconfig_validate_value,
)
from .git_gate_provision import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
_provision_dynamic_key,
_resolve_identity_file,
)
@dataclass(frozen=True)
class GitGatePlan:
"""Output of GitGate.prepare; consumed by .start.
The script + slug + upstream fields are filled at prepare time
(host-side, side-effect-free on docker). The network fields are
populated by the backend's launch step via `dataclasses.replace`
once those networks exist. Empty defaults are sentinels meaning
"not yet set"; `.start` validates that they are populated.
`hook_script` is the shared `pre-receive` for push-time gating;
`access_hook_script` is `git daemon`'s `--access-hook` for the
fetch-time upstream refresh."""
slug: str
entrypoint_script: Path
hook_script: Path
access_hook_script: Path
upstreams: tuple[GitGateUpstream, ...]
internal_network: str = ""
egress_network: str = ""
class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the gateway's
start/stop lifecycle is backend-specific and lives on concrete
subclasses."""
def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan:
"""Compute the upstream table from `bottle.git` and write the
entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess.
For `gitea` key entries, the returned upstream intentionally
has an empty identity file. Backend launch fills that in after
the operator confirms the preflight.
Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` via `dataclasses.replace`
before passing the plan to `.start`."""
upstreams = git_gate_upstreams_for_bottle(bottle)
entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
entrypoint.chmod(0o600)
hook = stage_dir / "git_gate_pre_receive.sh"
hook.write_text(git_gate_render_hook())
hook.chmod(0o600)
access_hook = stage_dir / "git_gate_access_hook.sh"
access_hook.write_text(git_gate_render_access_hook())
# 0o700 (not 0o600): git daemon execs --access-hook directly,
# not via `sh`, so the script needs the x bit. The gateway copy
# does not necessarily preserve this mode (`docker cp` does, the
# Apple `container cp` does not), so provision_git_gate re-applies
# +x on the gateway side — see backend/docker/gateway_provision.py.
access_hook.chmod(0o700)
upstreams_with_files: list[GitGateUpstream] = []
for u in upstreams:
known_hosts_file = Path()
if u.known_host_key:
known_hosts_file = stage_dir / f"{u.name}-known_hosts"
known_hosts_file.write_text(
git_gate_known_hosts_line(
u.upstream_host, u.upstream_port, u.known_host_key,
)
)
known_hosts_file.chmod(0o600)
upstreams_with_files.append(
GitGateUpstream(
name=u.name,
upstream_url=u.upstream_url,
upstream_host=u.upstream_host,
upstream_port=u.upstream_port,
identity_file=u.identity_file,
known_host_key=u.known_host_key,
known_hosts_file=known_hosts_file,
)
)
return GitGatePlan(
slug=slug,
entrypoint_script=entrypoint,
hook_script=hook,
access_hook_script=access_hook,
upstreams=tuple(upstreams_with_files),
)
__all__ = [
"GIT_GATE_HOSTNAME",
"GIT_GATE_TIMEOUT_SECS",
"GitGateUpstream",
"GitGatePlan",
"GitGate",
"git_gate_upstreams_for_bottle",
"git_gate_render_gitconfig",
"git_gate_known_hosts_line",
"git_gate_render_entrypoint",
"git_gate_render_provision",
"git_gate_render_hook",
"git_gate_render_access_hook",
"provision_git_gate_dynamic_keys",
"revoke_git_gate_provisioned_keys",
"_gitconfig_validate_value",
"_provision_dynamic_key",
"_resolve_identity_file",
]
+98
View File
@@ -0,0 +1,98 @@
"""Per-agent git-gate (PRD 0008).
The git-gate fronts a bottle's declared git upstreams as a transparent mirror:
a `pre-receive` hook gitleaks-scans pushes and forwards clean refs to the real
upstream with a gate-resident credential; an `--access-hook` refreshes from the
upstream before fetches. The agent never sees the upstream credential.
Layout:
* `service` the `GitGate` host-side service (prepare / provision / revoke /
preflight) the backend drives at launch.
* `plan` `GitGatePlan`, the launch DTO (in the backend contract).
* `provision`, `host_key` the deploy-key + host-key host-side helpers the
service delegates to.
The rendering + the in-gateway hook execution live in
`bot_bottle.gateway.git_gate.render`. The public names are re-exported lazily
via `__getattr__`, so `from bot_bottle.git_gate import ` keeps working and
importing `git_gate.plan` (the contract's dependency) doesn't drag in the
provisioning / forge-API code.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .service import GitGate
from .plan import GitGatePlan
from .provision import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ..gateway.git_gate.render import (
GIT_GATE_HOSTNAME,
GIT_GATE_TIMEOUT_SECS,
GitGateUpstream,
git_gate_known_hosts_line,
git_gate_render_access_hook,
git_gate_render_entrypoint,
git_gate_render_gitconfig,
git_gate_render_hook,
git_gate_render_provision,
git_gate_upstreams_for_bottle,
)
# Public name -> relative module that defines it. Render/hook names come from
# the gateway (where the in-gateway execution lives); the service, plan, and
# provisioning are this package's own submodules.
_LAZY: dict[str, str] = {
"GitGate": ".service",
"GitGatePlan": ".plan",
"provision_git_gate_dynamic_keys": ".provision",
"revoke_git_gate_provisioned_keys": ".provision",
"_provision_dynamic_key": ".provision",
"_resolve_identity_file": ".provision",
"GIT_GATE_HOSTNAME": "..gateway.git_gate.render",
"GIT_GATE_TIMEOUT_SECS": "..gateway.git_gate.render",
"GitGateUpstream": "..gateway.git_gate.render",
"git_gate_upstreams_for_bottle": "..gateway.git_gate.render",
"git_gate_render_gitconfig": "..gateway.git_gate.render",
"git_gate_known_hosts_line": "..gateway.git_gate.render",
"git_gate_render_entrypoint": "..gateway.git_gate.render",
"git_gate_render_provision": "..gateway.git_gate.render",
"git_gate_render_hook": "..gateway.git_gate.render",
"git_gate_render_access_hook": "..gateway.git_gate.render",
"_gitconfig_validate_value": "..gateway.git_gate.render",
}
def __getattr__(name: str) -> Any:
src = _LAZY.get(name)
if src is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
value = getattr(import_module(src, __name__), name)
globals()[name] = value
return value
__all__ = [
"GitGate",
"GitGatePlan",
"GitGateUpstream",
"GIT_GATE_HOSTNAME",
"GIT_GATE_TIMEOUT_SECS",
"git_gate_upstreams_for_bottle",
"git_gate_render_gitconfig",
"git_gate_known_hosts_line",
"git_gate_render_entrypoint",
"git_gate_render_provision",
"git_gate_render_hook",
"git_gate_render_access_hook",
"provision_git_gate_dynamic_keys",
"revoke_git_gate_provisioned_keys",
]
@@ -16,9 +16,9 @@ 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
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.
+36
View File
@@ -0,0 +1,36 @@
"""`GitGatePlan` — the git-gate launch plan (DTO).
The output of `GitGate.prepare`, consumed by the backend launch step and the
`BottlePlan` contract. A pure value type (its `upstreams` element type
`GitGateUpstream` is the neutral render dataclass).
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ..gateway.git_gate.render import GitGateUpstream
@dataclass(frozen=True)
class GitGatePlan:
"""Output of GitGate.prepare; consumed by .start.
The script + slug + upstream fields are filled at prepare time (host-side,
side-effect-free on docker). The network fields are populated by the
backend's launch step via `dataclasses.replace` once those networks exist.
Empty defaults are sentinels meaning "not yet set"; `.start` validates that
they are populated.
`hook_script` is the shared `pre-receive` for push-time gating;
`access_hook_script` is `git daemon`'s `--access-hook` for the fetch-time
upstream refresh."""
slug: str
entrypoint_script: Path
hook_script: Path
access_hook_script: Path
upstreams: tuple[GitGateUpstream, ...]
internal_network: str = ""
egress_network: str = ""
@@ -13,14 +13,14 @@ import dataclasses
from pathlib import Path
from typing import TYPE_CHECKING
from .bottle_state import globalize_slug
from .errors import MissingEnvVarError
from .log import info
from .manifest import ManifestBottle, ManifestGitEntry
from .git_gate_render import GitGateUpstream
from ..bottle_state import globalize_slug
from ..errors import MissingEnvVarError
from ..log import info
from ..manifest import ManifestBottle, ManifestGitEntry
from ..gateway.git_gate.render import GitGateUpstream
if TYPE_CHECKING:
from .git_gate import GitGatePlan
from .plan import GitGatePlan
def _provision_dynamic_key(
entry: ManifestGitEntry,
@@ -32,7 +32,7 @@ def _provision_dynamic_key(
Returns the host-side path to the private key file so the caller
can inject it into the GitGateUpstream as `identity_file`."""
from .deploy_key_provisioner import get_provisioner
from ..deploy_key_provisioner import get_provisioner
pk = entry.Key
token = os.environ.get(pk.forge_token_env)
if token is None:
@@ -70,7 +70,7 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
Called at teardown after containers stop. Raises if any revocation
fails a stranded key is a security concern that the operator must
address manually."""
from .deploy_key_provisioner import get_provisioner
from ..deploy_key_provisioner import get_provisioner
for entry in bottle.git:
if entry.Key.provider != "gitea":
continue
+114
View File
@@ -0,0 +1,114 @@
"""The `GitGate` host-side service (PRD 0008).
The git-gate fronts a bottle's declared git upstreams as a transparent mirror:
a `pre-receive` hook gitleaks-scans pushes and forwards clean refs to the real
upstream with a gate-resident credential; an `--access-hook` refreshes from the
upstream before fetches. The agent never sees the upstream credential.
`GitGate` is the host-side service the backend drives at launch: build the plan
(`prepare`), provision / revoke the per-upstream deploy keys, and preflight the
upstream host keys. The rendering it emits + the in-gateway hook execution live
in `bot_bottle.gateway.git_gate.render`.
"""
from __future__ import annotations
from pathlib import Path
from ..manifest import Manifest, ManifestBottle
from ..gateway.git_gate.render import (
GitGateUpstream,
git_gate_known_hosts_line,
git_gate_render_access_hook,
git_gate_render_entrypoint,
git_gate_render_hook,
git_gate_upstreams_for_bottle,
)
from .plan import GitGatePlan
from . import provision as _provision
from . import host_key as _host_key
class GitGate:
"""The per-agent git-gate host-side service. Stateless — the backend's
launch step drives `prepare` `provision_dynamic_keys` and, at teardown,
`revoke_provisioned_keys`; `preflight_host_keys` gates a launch on the
upstream host keys being known."""
def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan:
"""Compute the upstream table from `bottle.git` and write the
entrypoint, pre-receive hook, and access-hook scripts (mode 600) under
`stage_dir`. Pure host-side, no docker subprocess.
For `gitea` key entries, the returned upstream intentionally has an
empty identity file. Backend launch fills that in after the operator
confirms the preflight.
Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` via `dataclasses.replace` before
passing the plan to `.start`."""
upstreams = git_gate_upstreams_for_bottle(bottle)
entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
entrypoint.chmod(0o600)
hook = stage_dir / "git_gate_pre_receive.sh"
hook.write_text(git_gate_render_hook())
hook.chmod(0o600)
access_hook = stage_dir / "git_gate_access_hook.sh"
access_hook.write_text(git_gate_render_access_hook())
# 0o700 (not 0o600): git daemon execs --access-hook directly, not via
# `sh`, so the script needs the x bit. The gateway copy does not
# necessarily preserve this mode (`docker cp` does, the Apple `container
# cp` does not), so provisioning re-applies +x on the gateway side — see
# backend/docker/gateway_provision.py.
access_hook.chmod(0o700)
upstreams_with_files: list[GitGateUpstream] = []
for u in upstreams:
known_hosts_file = Path()
if u.known_host_key:
known_hosts_file = stage_dir / f"{u.name}-known_hosts"
known_hosts_file.write_text(
git_gate_known_hosts_line(
u.upstream_host, u.upstream_port, u.known_host_key,
)
)
known_hosts_file.chmod(0o600)
upstreams_with_files.append(
GitGateUpstream(
name=u.name,
upstream_url=u.upstream_url,
upstream_host=u.upstream_host,
upstream_port=u.upstream_port,
identity_file=u.identity_file,
known_host_key=u.known_host_key,
known_hosts_file=known_hosts_file,
)
)
return GitGatePlan(
slug=slug,
entrypoint_script=entrypoint,
hook_script=hook,
access_hook_script=access_hook,
upstreams=tuple(upstreams_with_files),
)
def provision_dynamic_keys(
self, bottle: ManifestBottle, plan: GitGatePlan, stage_dir: Path,
) -> GitGatePlan:
"""Mint the `gitea` upstreams' ephemeral deploy keys via the forge API
and return the plan with their identity files filled in."""
return _provision.provision_git_gate_dynamic_keys(bottle, plan, stage_dir)
def revoke_provisioned_keys(self, bottle: ManifestBottle, stage_dir: Path) -> None:
"""Revoke every deploy key provisioned for `bottle` (teardown)."""
_provision.revoke_git_gate_provisioned_keys(bottle, stage_dir)
def preflight_host_keys(
self, manifest: Manifest, *, headless: bool, home_md: Path | None,
) -> Manifest:
"""Ensure every git-gate upstream has a known host key, populating
missing ones (or erroring in headless mode). Returns the manifest,
possibly updated with fetched keys."""
return _host_key.preflight_host_keys(
manifest, headless=headless, home_md=home_md,
)
+1 -4
View File
@@ -5,10 +5,7 @@ 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
from .store.config_store import ConfigStore
class StaleImageError(Exception):
+118
View File
@@ -0,0 +1,118 @@
"""Manifest dataclasses (PRD 0011 layout).
Reads the per-file manifest tree:
$HOME/.bot-bottle/bottles/<name>.md one bottle per file
$HOME/.bot-bottle/agents/<name>.md home-resident agents
$CWD/.bot-bottle/agents/<name>.md cwd-supplied agents
Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is
the system prompt, for bottles the body is human documentation
(ignored by the parser).
Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... }
git-gate: # optional (PRD 0047)
user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp
supervise: <bool> # optional (default true)
nested_containers: <bool> # optional (default false)
Agent schema (frontmatter):
bottle: <bottle-name> # required
skills: [ <skill-name>, ... ] # optional
git-gate:
user: { name: <str>, email: <str> } # optional; overlays bottle
# Claude Code subagent passthrough fields — accepted, ignored:
name, description, model, color, memory
The agent file's Markdown body is the system prompt (stripped).
Unknown top-level frontmatter keys raise ManifestError with a hint.
Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
warn at load time and contributes nothing. The trust boundary is
expressed as filesystem layout rather than resolver logic.
Two types are exported:
ManifestIndex the multi-agent/bottle collection returned by
resolve() and from_json_obj(). Used for agent
selection (all_agent_names), validation
(require_agent), and lazy loading (load_for_agent).
This is the pre-preflight form.
Manifest a single-agent/bottle value type holding exactly
one agent: ManifestAgent and one bottle:
ManifestBottle (with the agent's git-gate.user
already overlaid). Returned by load_for_agent().
This is the post-preflight form passed to backends.
ManifestIndex.from_json_obj is preserved as a programmatic entry
point (used by tests) that takes a dict with the same field names
useful for building manifests without on-disk files.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .index import Manifest, ManifestIndex
from .util import ManifestError
from .agent import ManifestAgent, ManifestAgentProvider
from .bottle import ManifestBottle
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
# Facade name -> submodule that defines it. The aggregate model (`Manifest`,
# `ManifestIndex`) lives in `index`; the piece types in their own modules.
_LAZY_MODULES: dict[str, str] = {
"Manifest": "index",
"ManifestIndex": "index",
"ManifestError": "util",
"ManifestAgent": "agent",
"ManifestAgentProvider": "agent",
"ManifestBottle": "bottle",
"EGRESS_AUTH_SCHEMES": "egress",
"ManifestEgressRoute": "egress",
"ManifestEgressConfig": "egress",
"ManifestGitEntry": "git",
"ManifestGitUser": "git",
"ManifestKeyConfig": "git",
}
def __getattr__(name: str) -> Any:
"""Lazily surface the manifest facade names from their submodules and cache
them at package level so importing a leaf (e.g. `manifest.util`) doesn't
build the whole manifest model, while `from bot_bottle.manifest import
Manifest` keeps working."""
mod = _LAZY_MODULES.get(name)
if mod is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
value = getattr(import_module(f"{__name__}.{mod}"), name)
globals()[name] = value
return value
__all__ = [
"Manifest",
"ManifestIndex",
"ManifestError",
"ManifestGitEntry",
"ManifestGitUser",
"ManifestKeyConfig",
"ManifestAgentProvider",
"ManifestAgent",
"ManifestBottle",
"EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute",
"ManifestEgressConfig",
]
@@ -5,10 +5,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import cast
from .agent_provider import PROVIDER_TEMPLATES
from .manifest_util import ManifestError, as_json_object
from .manifest_git import ManifestGitUser
from .manifest_schema import AGENT_MODEL_KEYS, is_valid_entity_name
from ..agent_provider import PROVIDER_TEMPLATES
from .util import ManifestError, as_json_object
from .git import ManifestGitUser
from .schema import AGENT_MODEL_KEYS, is_valid_entity_name
@dataclass(frozen=True)
@@ -1,13 +1,13 @@
"""The `ManifestBottle` value type.
Split out of `manifest.py` so the `extends:`/loader resolvers can import it
without a circular dependency: `manifest.py` imports those resolvers, while
they only need this value type. Everything here depends on leaf modules
(`manifest_util`, `manifest_agent`, `manifest_egress`, `manifest_git`,
`manifest_schema`), so this module sits at the bottom of the manifest layer.
Split out of the package facade (`manifest/__init__.py`) so the `extends:`/
loader resolvers can import it without a circular dependency: the facade
imports those resolvers, while they only need this value type. Everything here
depends on leaf modules (`util`, `agent`, `egress`, `git`, `schema`), so this
module sits at the bottom of the manifest layer.
`manifest.py` re-exports `ManifestBottle`, so existing
`from .manifest import ManifestBottle` callers are unaffected.
The facade re-exports `ManifestBottle`, so existing
`from bot_bottle.manifest import ManifestBottle` callers are unaffected.
"""
from __future__ import annotations
@@ -15,11 +15,11 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Mapping
from .manifest_util import ManifestError, as_json_object
from .manifest_agent import ManifestAgentProvider
from .manifest_egress import ManifestEgressConfig
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS
from .util import ManifestError, as_json_object
from .agent import ManifestAgentProvider
from .egress import ManifestEgressConfig
from .git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
from .schema import BOTTLE_KEYS
__all__ = ["ManifestBottle"]
@@ -6,7 +6,7 @@ import re
from dataclasses import dataclass
from typing import cast
from .manifest_util import ManifestError, as_json_object
from .util import ManifestError, as_json_object
EGRESS_AUTH_SCHEMES = ("Bearer", "token")
@@ -2,10 +2,10 @@
from __future__ import annotations
from .manifest_bottle import ManifestBottle
from .manifest_egress import ManifestEgressConfig, validate_egress_routes
from .manifest_git import ManifestGitUser, parse_git_gate_config
from .manifest_util import ManifestError, as_json_object
from .bottle import ManifestBottle
from .egress import ManifestEgressConfig, validate_egress_routes
from .git import ManifestGitUser, parse_git_gate_config
from .util import ManifestError, as_json_object
def _overlay_declared_bool(
@@ -5,7 +5,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from .manifest_util import ManifestError, as_json_object
from .util import ManifestError, as_json_object
# Shell-safe characters for git-gate repo names. Names are embedded in
# the generated entrypoint shell script (shlex.quote is the primary
@@ -1,59 +1,10 @@
"""Manifest dataclasses (PRD 0011 layout).
"""The `Manifest` / `ManifestIndex` aggregate model.
Reads the per-file manifest tree:
$HOME/.bot-bottle/bottles/<name>.md one bottle per file
$HOME/.bot-bottle/agents/<name>.md home-resident agents
$CWD/.bot-bottle/agents/<name>.md cwd-supplied agents
Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is
the system prompt, for bottles the body is human documentation
(ignored by the parser).
Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... }
git-gate: # optional (PRD 0047)
user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp
supervise: <bool> # optional (default true)
nested_containers: <bool> # optional (default false)
Agent schema (frontmatter):
bottle: <bottle-name> # required
skills: [ <skill-name>, ... ] # optional
git-gate:
user: { name: <str>, email: <str> } # optional; overlays bottle
# Claude Code subagent passthrough fields — accepted, ignored:
name, description, model, color, memory
The agent file's Markdown body is the system prompt (stripped).
Unknown top-level frontmatter keys raise ManifestError with a hint.
Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
warn at load time and contributes nothing. The trust boundary is
expressed as filesystem layout rather than resolver logic.
Two types are exported:
ManifestIndex the multi-agent/bottle collection returned by
resolve() and from_json_obj(). Used for agent
selection (all_agent_names), validation
(require_agent), and lazy loading (load_for_agent).
This is the pre-preflight form.
Manifest a single-agent/bottle value type holding exactly
one agent: ManifestAgent and one bottle:
ManifestBottle (with the agent's git-gate.user
already overlaid). Returned by load_for_agent().
This is the post-preflight form passed to backends.
ManifestIndex.from_json_obj is preserved as a programmatic entry
point (used by tests) that takes a dict with the same field names
useful for building manifests without on-disk files.
The parsed manifest document (`Manifest`) and the multi-agent index over it
(`ManifestIndex`), plus the bottle-resolution helpers that stitch the pieces
(`agent`, `bottle`, `egress`, `git`, `extends`, `loader`, `schema`) into an
effective manifest. This is the heavy aggregate the thin package `__init__`
re-exports `Manifest` / `ManifestIndex` lazily.
"""
from __future__ import annotations
@@ -63,41 +14,20 @@ from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Mapping
from .log import warn
from .manifest_util import ManifestError, as_json_object
from .manifest_agent import ManifestAgent, ManifestAgentProvider
from .manifest_bottle import ManifestBottle
from .manifest_egress import (
EGRESS_AUTH_SCHEMES,
ManifestEgressConfig,
ManifestEgressRoute,
)
from .manifest_extends import merge_bottles_runtime, resolve_bottles
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
from .manifest_loader import (
from ..log import warn
from .util import ManifestError, as_json_object
from .agent import ManifestAgent
from .bottle import ManifestBottle
from .extends import merge_bottles_runtime, resolve_bottles
from .git import ManifestGitUser
from .loader import (
check_stale_json,
load_bottle_chain_from_dir,
scan_agent_names,
scan_bottle_names,
)
from .manifest_schema import validate_agent_frontmatter_keys
from .yaml_subset import YamlSubsetError, parse_frontmatter
# Re-export everything that callers currently import from this module.
__all__ = [
"ManifestError",
"ManifestGitEntry",
"ManifestGitUser",
"ManifestKeyConfig",
"ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute",
"ManifestEgressConfig",
"ManifestAgent",
"ManifestBottle",
"ManifestIndex",
"Manifest",
]
from .schema import validate_agent_frontmatter_keys
from ..yaml_subset import YamlSubsetError, parse_frontmatter
def _section_dict(value: object, label: str) -> dict[str, object]:
@@ -4,15 +4,15 @@ from __future__ import annotations
from pathlib import Path
from .log import warn
from .manifest_bottle import ManifestBottle
from .manifest_extends import resolve_bottles
from .manifest_schema import (
from ..log import warn
from .bottle import ManifestBottle
from .extends import resolve_bottles
from .schema import (
entity_name_from_path,
validate_bottle_frontmatter_keys,
)
from .manifest_util import ManifestError
from .yaml_subset import YamlSubsetError, parse_frontmatter
from .util import ManifestError
from ..yaml_subset import YamlSubsetError, parse_frontmatter
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
@@ -68,7 +68,7 @@ def _validate_frontmatter_keys(
keys: object,
allowed_keys: frozenset[str],
) -> None:
from .manifest_util import ManifestError
from .util import ManifestError
key_set = set(keys) # type: ignore
unknown = key_set - allowed_keys # type: ignore
+62 -23
View File
@@ -5,19 +5,19 @@ A single persistent per-host service that will run the gateway functions
agent launches. This package is being built bottom-up, starting with the
backend-neutral "consolidation core" that needs no VM packaging:
* `registry` the SQLite runtime-state store + fail-closed
* `store.registry_store` the SQLite runtime-state store + fail-closed
attribution (source IP + per-bottle identity token).
* `broker` the signed, structured launch-request contract + a
`LaunchBroker` (stub for the harness) that verifies
provenance before acting.
* `gateway` the consolidated per-host gateway: a `Gateway`
lifecycle contract (idempotent singleton) + a
`DockerGateway` impl. One gateway shared by all
bottles instead of one per bottle.
* `gateway` the consolidated per-host gateway: the `Gateway`
lifecycle contract (idempotent singleton). One
gateway shared by all bottles instead of one per
bottle; backend impls live under `backend/*/gateway`.
* `service` the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the
shared gateway, attributes.
* `control_plane` the HTTP control-plane RPC (launch / teardown /
* `server` the HTTP control-plane RPC (launch / teardown /
list / attribute / gateway / health).
The actual backend-native launch (a real docker/firecracker broker) and
@@ -28,19 +28,59 @@ orchestrator -> firecracker).
from __future__ import annotations
from .registry import BottleRecord, RegistryStore, new_identity_token
from .broker import (
BrokerAuthError,
LaunchBroker,
LaunchRequest,
StubBroker,
sign_request,
verify_request,
)
from .docker_broker import DockerBroker, DockerBrokerError
from .gateway import DockerGateway, Gateway, GatewayError
from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .store.registry_store import BottleRecord, RegistryStore, new_identity_token
from .broker import (
BrokerAuthError,
LaunchBroker,
LaunchRequest,
StubBroker,
sign_request,
verify_request,
)
from .docker_broker import DockerBroker, DockerBrokerError
from ..gateway import Gateway, GatewayError
from .service import Orchestrator
from .server import OrchestratorServer, dispatch, make_server
# Facade name -> submodule that defines it. Lazy so importing a leaf (or the
# parent package, e.g. via `orchestrator.store.store_manager`) doesn't drag the
# whole orchestrator — server, docker_broker + the docker backend, http — while
# `from bot_bottle.orchestrator import RegistryStore` keeps working.
_LAZY: dict[str, str] = {
"BottleRecord": ".store.registry_store",
"RegistryStore": ".store.registry_store",
"new_identity_token": ".store.registry_store",
"BrokerAuthError": ".broker",
"LaunchBroker": ".broker",
"LaunchRequest": ".broker",
"StubBroker": ".broker",
"sign_request": ".broker",
"verify_request": ".broker",
"DockerBroker": ".docker_broker",
"DockerBrokerError": ".docker_broker",
"Gateway": "..gateway",
"GatewayError": "..gateway",
"Orchestrator": ".service",
"OrchestratorServer": ".server",
"dispatch": ".server",
"make_server": ".server",
}
def __getattr__(name: str) -> Any:
src = _LAZY.get(name)
if src is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
value = getattr(import_module(src, __name__), name)
globals()[name] = value
return value
__all__ = [
"BottleRecord",
@@ -50,15 +90,14 @@ __all__ = [
"LaunchBroker",
"LaunchRequest",
"StubBroker",
"sign_request",
"verify_request",
"DockerBroker",
"DockerBrokerError",
"Gateway",
"DockerGateway",
"GatewayError",
"sign_request",
"verify_request",
"Orchestrator",
"ControlPlaneServer",
"OrchestratorServer",
"dispatch",
"make_server",
]
+4 -22
View File
@@ -16,13 +16,12 @@ import secrets
from pathlib import Path
from .. import log
from ..store_manager import StoreManager
from .store.store_manager import StoreManager
from .broker import LaunchBroker, StubBroker
from .control_plane import make_server
from .server import make_server
from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path
from .store.registry_store import RegistryStore, default_db_path
from .service import Orchestrator
from .gateway import DockerGateway, Gateway
def main(argv: list[str] | None = None) -> int:
@@ -38,10 +37,6 @@ def main(argv: list[str] | None = None) -> int:
"--broker", choices=("stub", "docker"), default="stub",
help="launch broker: 'stub' records requests; 'docker' runs containers",
)
parser.add_argument(
"--gateway", action="store_true",
help="run one consolidated per-host gateway (build-if-missing)",
)
args = parser.parse_args(argv)
registry = RegistryStore(args.db)
@@ -57,20 +52,7 @@ def main(argv: list[str] | None = None) -> int:
# anything; 'docker' runs real containers (firecracker drops in later).
secret = secrets.token_bytes(32)
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
# Standalone `--gateway` (not the consolidated flow, where the host
# lifecycle runs the gateway). The gateway resolves against this same
# process; the URL is only reachable when they share a docker network.
gateway: Gateway | None = (
DockerGateway(orchestrator_url=f"http://{args.host}:{args.port}")
if args.gateway else None
)
orchestrator = Orchestrator(registry, broker, secret, gateway)
# One persistent per-host gateway, shared by every bottle: build the
# bundle image if missing, then bring the singleton up (idempotent).
if gateway is not None:
orchestrator.ensure_gateway()
log.info("consolidated gateway ensured", context={"name": gateway.name})
orchestrator = Orchestrator(registry, broker, secret)
server = make_server(orchestrator, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1]
+15 -12
View File
@@ -18,19 +18,22 @@ import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from ..paths import host_control_plane_token
from .control_plane import CONTROL_AUTH_HEADER
from ..orchestrator_auth import ROLE_CLI, mint
from ..paths import host_orchestrator_token
from .server import ORCHESTRATOR_AUTH_HEADER
DEFAULT_TIMEOUT_SECONDS = 5.0
def _host_auth_token() -> str:
"""The per-host control-plane secret, or "" if it can't be read. "" means
'send no auth header' correct against an open (unconfigured) control
plane, and harmlessly rejected by a secured one."""
"""A freshly minted `cli`-role token, signed with the host control-plane
key, or "" if the key can't be read. The host CLI shares the orchestrator's
trust domain, so it holds the signing key and mints its own operator token;
"" means 'send no auth header' correct against an open (unconfigured)
control plane, and harmlessly rejected by a secured one."""
try:
return host_control_plane_token()
except OSError:
return mint(ROLE_CLI, host_orchestrator_token())
except (OSError, ValueError):
return ""
@@ -80,7 +83,7 @@ class OrchestratorClient:
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
if self._auth_token:
headers[CONTROL_AUTH_HEADER] = self._auth_token
headers[ORCHESTRATOR_AUTH_HEADER] = self._auth_token
req = urllib.request.Request(
f"{self._base}{path}", data=data, method=method, headers=headers,
)
@@ -249,14 +252,14 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
candidates.append("http://127.0.0.1:8099")
try: # firecracker: infra VM control plane on the orchestrator TAP
from ..backend.firecracker import netpool
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT
from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
candidates.append(
f"http://{netpool.orch_slot().guest_ip}:{CONTROL_PLANE_PORT}")
f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
except Exception: # noqa: BLE001 — backend optional / not firecracker
pass
try: # macOS: infra container control plane on its host-only address
from ..backend.macos_container.infra import probe_control_plane_url
url = probe_control_plane_url()
from ..backend.macos_container.infra import probe_orchestrator_url
url = probe_orchestrator_url()
if url:
candidates.append(url)
except Exception: # noqa: BLE001 — backend optional / not macOS
+1 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import subprocess
from ..docker_cmd import run_docker
from ..backend.docker.util import run_docker
from .broker import LaunchBroker, LaunchRequest
CONTAINER_PREFIX = "bot-bottle-orch-"
+9 -239
View File
@@ -1,94 +1,29 @@
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).
"""Shared orchestrator-process constants + helpers (PRD 0070).
Runs both the orchestrator control plane and the gateway data plane inside
a single `bot-bottle-infra` container on the shared gateway network
matching the structure already used by the macOS and Firecracker backends.
`gateway_init` is PID 1 and supervises both; the infra container is an
idempotent per-host singleton.
The combined container replaces the prior two-container split
(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches
the control plane via a published loopback port; gateway daemons reach it
over 127.0.0.1 (same container).
Backend-neutral pieces the per-backend infra services build on: the
control-plane port, the startup timeout, the start-error, and the source hash
used to detect a code change. The per-backend infra lifecycle lives with each
backend docker: `backend.docker.infra.DockerInfraService`; macOS:
`backend.macos_container.infra`; firecracker: `backend.firecracker.infra_vm`.
"""
from __future__ import annotations
import hashlib
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from .. import log
from ..docker_cmd import run_docker
from ..paths import (
CONTROL_PLANE_TOKEN_ENV,
bot_bottle_root,
host_control_plane_token,
host_gateway_ca_dir,
)
from ..supervise import DB_PATH_IN_CONTAINER
from .gateway import (
GATEWAY_DOCKERFILE,
GATEWAY_IMAGE,
GATEWAY_NETWORK,
GatewayError,
MITMPROXY_HOME,
_host_db_dir,
)
DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
INFRA_NAME = "bot-bottle-infra"
INFRA_LABEL = "bot-bottle-infra=1"
# The combined infra image: gateway data plane + orchestrator content.
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator).
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest")
INFRA_DOCKERFILE = "Dockerfile.infra"
# Baked as a container label so `ensure_running` can detect whether the
# running container is executing the current bind-mounted source.
INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash"
# Orchestrator image: the single canonical definition of the control-plane
# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git).
# Used as a build intermediate: `Dockerfile.infra` COPY --from this image.
ORCHESTRATOR_IMAGE = os.environ.get(
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# The gateway daemons + orchestrator the infra container runs.
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS).
_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
# The bind-mount path for the live control-plane source inside the
# container. Separate from /app so the gateway's baked scripts
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount inside the container (DB + state).
_ROOT_IN_CONTAINER = "/bot-bottle-root"
# The supervise daemon writes proposals into the host DB directory.
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
_REPO_ROOT = Path(__file__).resolve().parents[2]
class OrchestratorStartError(RuntimeError):
"""The infra container did not become healthy within the timeout."""
"""The infra container/VM did not become healthy within the timeout."""
def source_hash(repo_root: Path) -> str:
"""Content hash of the orchestrator's bind-mounted Python source (the
`bot_bottle` package the control-plane process imports). Changes only
when the code that would actually run changes `ensure_running`
when the code that would actually run changes a backend's `ensure_running`
recreates the container on a mismatch so a code change takes effect,
but leaves a healthy up-to-date container alone to preserve in-memory
egress tokens."""
@@ -99,174 +34,9 @@ def source_hash(repo_root: Path) -> str:
return h.hexdigest()
class OrchestratorService:
"""Manages the single per-host infra container (control plane + gateway).
Callers only need `ensure_running()` + `url`.
`infra_name` / `infra_label` let backends run independent infra containers
on the same host without name collisions (e.g. isolated integration tests
that can't share the production INFRA_NAME singleton)."""
def __init__(
self,
*,
port: int = DEFAULT_PORT,
network: str = GATEWAY_NETWORK,
image: str = INFRA_IMAGE,
repo_root: Path = _REPO_ROOT,
host_root: Path | None = None,
infra_name: str = INFRA_NAME,
infra_label: str = INFRA_LABEL,
) -> None:
self.port = port
self.network = network
self.image = image
self._repo_root = repo_root
self._host_root = host_root or bot_bottle_root()
self._infra_name = infra_name
self._infra_label = infra_label
@property
def url(self) -> str:
"""Host-side control-plane URL (published loopback port)."""
return f"http://127.0.0.1:{self.port}"
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
try:
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _container_running(self, name: str) -> bool:
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
return name in proc.stdout.split()
def _infra_source_current(self, current_hash: str) -> bool:
"""True iff the running infra container was started from the current
bind-mounted source. Mirrors the macOS backend's `_source_current`."""
if not self._container_running(self._infra_name):
return False
proc = run_docker([
"docker", "inspect", "--format",
"{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}",
self._infra_name,
])
if proc.returncode != 0:
return True # can't compare → don't churn a working container
return proc.stdout.strip() == current_hash
def _ensure_network(self) -> None:
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
return
proc = run_docker(["docker", "network", "create", self.network])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def _build_images(self) -> None:
"""Build the gateway base, the orchestrator intermediate, then the
infra image. All are cache-aware: a no-op when nothing changed."""
for tag, dockerfile in (
(GATEWAY_IMAGE, GATEWAY_DOCKERFILE),
(ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE),
(self.image, INFRA_DOCKERFILE),
):
argv = ["docker", "build", "-t", tag,
"-f", str(self._repo_root / dockerfile),
str(self._repo_root)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache")
proc = run_docker(argv)
if proc.returncode != 0:
raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")
def _run_infra_container(self, current_hash: str) -> None:
"""Start the combined infra container (idempotent: clears a stale
fixed-name container first). Labels the container with `current_hash`
so a later `ensure_running` can detect a real code change."""
self._ensure_network()
run_docker(["docker", "rm", "--force", self._infra_name])
proc = run_docker([
"docker", "run", "--detach",
"--name", self._infra_name,
"--label", self._infra_label,
"--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}",
"--network", self.network,
# Host CLI reaches the control plane here (loopback only).
# gateway_init always starts the orchestrator on DEFAULT_PORT (8099)
# inside the container; self.port is the host-side published port.
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
# Persist the mitmproxy CA on the host so it survives container
# recreation AND docker volume pruning (issue #450): every agent
# trusts this one CA, so a fresh one would break all running bottles.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# Shared supervise DB (same file the operator reads over HTTP).
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
# Live control-plane source, mounted to a path that does not
# overlay the gateway's baked /app scripts.
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
# PYTHONPATH lets the orchestrator (and other Python daemons)
# import the live source ahead of the installed package.
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
# Orchestrator registry DB on the host (sole writer: control plane).
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
# Control-plane secret: required by the orchestrator (to enforce)
# and by the gateway daemons (to present on /resolve calls).
"--env", CONTROL_PLANE_TOKEN_ENV,
# Gateway daemons reach the orchestrator over loopback at its
# fixed internal port (DEFAULT_PORT), independent of self.port.
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
# Opt the orchestrator into gateway_init's supervise tree.
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
self.image,
], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
if proc.returncode != 0:
raise OrchestratorStartError(
f"infra container failed to start: {proc.stderr.strip()}"
)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> str:
"""Ensure the infra container (control plane + gateway) is up; return
the host control-plane URL. Idempotent a healthy container on current
source is left untouched. Raises `OrchestratorStartError` on timeout."""
self._build_images()
current_hash = source_hash(self._repo_root)
if self.is_healthy() and self._infra_source_current(current_hash):
return self.url
log.info("starting infra container", context={"name": self._infra_name})
self._run_infra_container(current_hash)
deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline:
if self.is_healthy():
log.info("infra container healthy", context={"url": self.url})
return self.url
time.sleep(_HEALTH_POLL_SECONDS)
raise OrchestratorStartError(
f"infra container at {self.url} did not become healthy within {startup_timeout:g}s"
)
def stop(self) -> None:
"""Remove the infra container (idempotent)."""
run_docker(["docker", "rm", "--force", self._infra_name])
__all__ = [
"OrchestratorService",
"OrchestratorStartError",
"INFRA_NAME",
"INFRA_IMAGE",
"INFRA_SOURCE_HASH_LABEL",
"ORCHESTRATOR_IMAGE",
"DEFAULT_PORT",
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
"OrchestratorStartError",
"source_hash",
]
+3 -3
View File
@@ -23,10 +23,10 @@ from __future__ import annotations
import sys
from pathlib import Path
from ..docker_cmd import run_docker
from ..backend.docker.util import run_docker
from ..paths import host_gateway_ca_dir
from .gateway import GATEWAY_NAME, rotate_gateway_ca
from .lifecycle import INFRA_NAME
from ..gateway import GATEWAY_NAME, rotate_gateway_ca
from ..backend.docker.infra import INFRA_NAME
# The containers whose mitmproxy would still be serving the old CA from memory:
# the consolidated infra container and the standalone per-host gateway.
@@ -27,6 +27,20 @@ vsock / unix-socket portability caveats):
POST /supervise/respond -> 200 {"responded": true} | 409 (operator)
body: {"proposal_id","bottle_slug",
"decision", ["notes"],["final_file"]}
POST /supervise/propose -> 201 {"proposal_id"} | 403 (agent)
body: {"source_ip","identity_token",
"tool","proposed_file","justification"}
POST /supervise/poll -> 200 {"status", ["notes"],["final_file"]} | 403
body: {"source_ip","identity_token",
"proposal_id"}
The `/supervise/propose` + `/supervise/poll` pair is the **agent** half of the
supervise flow: the data plane (supervise / egress / git-gate) queues a proposal
and polls for its response over RPC instead of opening `bot-bottle.db` directly.
`poll` is idempotent it never archives, so a dropped connection can't lose an
operator decision (the row is reaped when the bottle is torn down / reconciled).
Both attribute the caller by `(source_ip, identity_token)` exactly like
`/resolve`, so a bottle can only ever queue or read its own proposals.
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
tear down) the bottle in the registry AND broker the backend-native launch
@@ -34,14 +48,13 @@ via the orchestrator. Register/deregister without a launch are internal to
`Orchestrator`, not exposed here.
Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
without a socket; `Handler` / `OrchestratorServer` / `make_server` are a
thin stdlib adapter around it. Listing redacts identity tokens they are
returned only once, to the caller that launches the bottle.
"""
from __future__ import annotations
import hmac
import http.server
import json
import os
@@ -50,19 +63,39 @@ import sys
import typing
from urllib.parse import urlsplit
from ..paths import CONTROL_PLANE_TOKEN_ENV
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
from ..paths import ORCHESTRATOR_TOKEN_ENV
from ..supervisor.types import TOOLS
from .service import Orchestrator
# JSON body payload type (parsed request / rendered response).
Json = dict[str, object]
# The request header carrying the per-host control-plane secret. Every route
# except `GET /health` requires it (see `dispatch`). The trusted callers hold
# the secret (the gateway's PolicyResolver, the host CLI's OrchestratorClient);
# an agent that can merely *reach* the port cannot present it, so it can't
# enumerate bottles, rewrite policy, read injected upstream tokens, or approve
# its own supervise proposals.
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
# The request header carrying the caller's role-scoped control-plane token (a
# signed JWT naming the caller's role — see orchestrator_auth). The role gates which
# routes the caller may reach: the data plane holds a `gateway` token good only
# for the agent-facing lookups; the host CLI holds a `cli` token for the
# operator/mutating routes. An agent that can merely *reach* the port holds no
# token at all, and a compromised gateway holds only `gateway` — neither can
# drive the operator routes (approve proposals, rewrite policy, read tokens).
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
# The routes the data plane (role `gateway`) is allowed to reach — exactly the
# per-request lookups PolicyResolver makes. Every other authenticated route is
# operator-only. `cli` is a superset role: it may reach any route.
_GATEWAY_ROUTES: frozenset[tuple[str, str]] = frozenset({
("POST", "/resolve"),
("POST", "/supervise/propose"),
("POST", "/supervise/poll"),
})
def _allowed_roles(method: str, route: str) -> frozenset[str]:
"""The roles permitted on `(method, route)`: `gateway` or `cli` on the
data-plane routes, `cli`-only everywhere else."""
if (method, route) in _GATEWAY_ROUTES:
return ROLES
return frozenset({ROLE_CLI})
def _parse_json_object(body: bytes) -> Json:
@@ -76,28 +109,33 @@ def _parse_json_object(body: bytes) -> Json:
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
orch: Orchestrator, method: str, path: str, body: bytes, *, authorized: bool = True,
orch: Orchestrator, method: str, path: str, body: bytes, *, role: str | None = ROLE_CLI,
) -> tuple[int, Json]:
"""Route one control-plane request to a (status, payload) pair. Pure —
no I/O beyond the orchestrator so it is fully testable without a socket.
`authorized` is whether the request presented the control-plane secret (or
no secret is configured see `ControlPlaneServer`). Every route except
`GET /health` requires it: the source-IP + identity-token checks inside
`/resolve` and `/attribute` authenticate the *bottle* a request is about,
not the *caller*, so without this gate any agent that can reach the port
could rewrite another bottle's policy, read the injected upstream tokens,
or approve its own supervise proposals. Defaults True so unit tests of the
routing logic don't have to thread it through."""
`role` is the caller's verified control-plane role (`gateway` or `cli`), or
None for an unauthenticated request; an open-mode server (no signing key
configured see `OrchestratorServer`) passes `cli`. Every route except
`GET /health` requires a role: a missing role is 401, and a role that
doesn't cover the route is 403 — so a `gateway` data-plane token can reach
`/resolve` + `/supervise/{propose,poll}` but not the operator routes
(rewrite policy, read injected tokens, approve its own supervise proposals).
The source-IP + identity-token checks inside `/resolve` and `/attribute`
authenticate the *bottle* a request is about, not the *caller*, so this role
gate is what protects the caller-privileged routes. Defaults `cli` so unit
tests of the routing logic don't have to thread it through."""
route = urlsplit(path).path.rstrip("/") or "/"
if method == "GET" and route == "/health":
return 200, {"status": "ok"}
if not authorized:
# Everything below is a trusted-caller operation. Deny before touching
# the registry / broker / supervise store.
# Role gate — every route below is a trusted-caller operation. Deny before
# touching the registry / broker / supervise store.
if role is None:
return 401, {"error": "control-plane authentication required"}
if role not in _allowed_roles(method, route):
return 403, {"error": "insufficient role for this route"}
if method == "GET" and route == "/gateway":
return 200, orch.gateway_status()
@@ -235,6 +273,56 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
return 200, {"responded": True}
return 409, {"error": err}
if method == "POST" and route == "/supervise/propose":
# Agent half: queue a proposal, attributed to the caller resolved from
# (source_ip, identity_token) — never a caller-supplied slug — so the
# data plane can't forge attribution. Fail-closed 403 when unattributed.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
tool = data.get("tool")
proposed_file = data.get("proposed_file")
justification = data.get("justification")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
if not isinstance(tool, str) or tool not in TOOLS:
return 400, {"error": f"tool (string) must be one of {TOOLS}"}
if not isinstance(proposed_file, str) or not proposed_file:
return 400, {"error": "proposed_file (string) is required"}
if not isinstance(justification, str) or not justification:
return 400, {"error": "justification (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
proposal_id = orch.supervise_queue_proposal(
rec.bottle_id, tool=tool, proposed_file=proposed_file,
justification=justification,
)
return 201, {"proposal_id": proposal_id}
if method == "POST" and route == "/supervise/poll":
# Agent half: non-blocking read of the caller's own proposal decision.
# Attributed like /propose, and scoped to the resolved bottle id, so a
# guessed proposal_id can never read another bottle's response.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
proposal_id = data.get("proposal_id")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
if not isinstance(proposal_id, str) or not proposal_id:
return 400, {"error": "proposal_id (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
return 200, orch.supervise_poll_response(rec.bottle_id, proposal_id)
if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant gateway makes: returns the
# bottle's policy. Requires a matching (source_ip, identity_token)
@@ -277,13 +365,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
crashing the connection, so one bad request can't take the control
plane down for the caller."""
server = self.server
assert isinstance(server, ControlPlaneServer)
assert isinstance(server, OrchestratorServer)
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b""
authorized = server.is_authorized(self.headers.get(CONTROL_AUTH_HEADER, ""))
role = server.role_for(self.headers.get(ORCHESTRATOR_AUTH_HEADER, ""))
try:
status, payload = dispatch(
server.orchestrator, method, self.path, body, authorized=authorized)
server.orchestrator, method, self.path, body, role=role)
except Exception as e: # noqa: BLE001 — the control plane must stay up
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
sys.stderr.flush()
@@ -308,26 +396,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
self._serve("DELETE")
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""Threading HTTP server that carries the orchestrator for its handlers.
Holds the per-host control-plane secret (from `$BOT_BOTTLE_CONTROL_PLANE_TOKEN`,
injected by the launcher into this container only). When a secret is set,
every route but `/health` requires it; when it is unset the server runs
**open** and says so loudly at startup a fail-visible fallback for tests
and any backend that hasn't wired the secret yet (e.g. Firecracker, whose
nft boundary already blocks agents from the control-plane port)."""
Holds the per-host control-plane *signing key* (from
`$BOT_BOTTLE_ORCHESTRATOR_TOKEN`, injected by the launcher into the
orchestrator process only) and verifies each request's role-scoped token
against it. When a key is set, every route but `/health` requires a valid
token whose role covers the route; when it is unset the server runs **open**
(full `cli` access) and says so loudly at startup a fail-visible fallback
for tests and any backend that hasn't wired the key yet (e.g. Firecracker,
whose nft boundary already blocks agents from the control-plane port)."""
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
self.orchestrator = orchestrator
self._auth_token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
if not self._auth_token:
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
if not self._signing_key:
sys.stderr.write(
"orchestrator: WARNING — no control-plane secret "
f"(${CONTROL_PLANE_TOKEN_ENV}); running WITHOUT caller "
"orchestrator: WARNING — no control-plane signing key "
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
"authentication. Any client that can reach this port can drive "
"it. Backends that put the control plane on an agent-reachable "
"network MUST set this.\n"
@@ -335,24 +425,26 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
sys.stderr.flush()
super().__init__(address, Handler)
def is_authorized(self, presented: str) -> bool:
"""True iff the request may proceed past `/health`: either no secret is
configured (open mode) or the presented header matches it. Constant-time
compare so a wrong token leaks nothing timing-wise."""
if not self._auth_token:
return True
return hmac.compare_digest(presented, self._auth_token)
def role_for(self, presented: str) -> str | None:
"""The role the request is authorized as, or None if unauthenticated.
Open mode (no signing key) grants full `cli` access the fail-visible
fallback. Otherwise verify the presented signed token; a missing/invalid
token yields None ( 401), a valid one yields its `gateway`/`cli`
role ( per-route 401/403 in `dispatch`)."""
if not self._signing_key:
return ROLE_CLI
return verify(presented, self._signing_key)
def make_server(
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
) -> ControlPlaneServer:
) -> OrchestratorServer:
"""Build (but do not start) a control-plane server. `port=0` binds an
ephemeral port read `server.server_address` for the actual one."""
return ControlPlaneServer((host, port), orchestrator)
return OrchestratorServer((host, port), orchestrator)
__all__ = [
"dispatch", "Handler", "ControlPlaneServer", "make_server", "Json",
"CONTROL_AUTH_HEADER",
"dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
"ORCHESTRATOR_AUTH_HEADER",
]
+89 -32
View File
@@ -26,23 +26,22 @@ from collections.abc import Iterable
from datetime import datetime, timezone
from .broker import LaunchBroker, LaunchRequest, sign_request
from .registry import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore
from .gateway import Gateway
from ..supervise import (
from .store.registry_store import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore
from .supervisor import (
AuditEntry,
COMPONENT_FOR_TOOL,
POLL_STATUS_PENDING,
POLL_STATUS_UNKNOWN,
Proposal,
Response,
STATUS_APPROVED,
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
list_all_pending_proposals,
read_proposal,
render_diff,
write_audit_entry,
write_response,
Supervisor,
)
from ..util import render_diff
# Operator decision → Response.status. The apply half (egress tools) runs
@@ -65,12 +64,14 @@ class Orchestrator:
registry: RegistryStore,
broker: LaunchBroker,
sign_secret: bytes,
gateway: Gateway | None = None,
supervisor: Supervisor | None = None,
) -> None:
self.registry = registry
self._broker = broker
self._secret = sign_secret
self._gateway = gateway
# The supervise service (queue + audit I/O). Injectable so tests can
# point it at a temp DB; defaults to the host DB.
self._supervisor = supervisor or Supervisor()
# Per-bottle egress auth tokens (env_name -> value), keyed by bottle_id.
# Held **in memory only** — never written to the registry DB — so the
# gateway can inject each bottle's upstream credential without secrets
@@ -100,7 +101,7 @@ class Orchestrator:
if tokens:
self._tokens[rec.bottle_id] = dict(tokens)
if env_var_secret:
from .secret_store import encrypt_value
from .store.secret_store import encrypt_value
encrypted = {k: encrypt_value(env_var_secret, v) for k, v in tokens.items()}
self.registry.store_agent_secrets(rec.bottle_id, encrypted)
req = LaunchRequest(
@@ -129,6 +130,8 @@ class Orchestrator:
self._broker.submit(sign_request(req, self._secret))
self.registry.deregister(bottle_id)
self._tokens.pop(bottle_id, None)
# Reap any supervise proposals the gone bottle never acknowledged.
self._supervisor.archive_all_proposals(bottle_id)
return True
def reconcile(
@@ -153,6 +156,8 @@ class Orchestrator:
live_source_ips, grace_seconds=grace_seconds)
for rec in reaped:
self._tokens.pop(rec.bottle_id, None)
# Reap any supervise proposals the gone bottle never acknowledged.
self._supervisor.archive_all_proposals(rec.bottle_id)
return [rec.bottle_id for rec in reaped]
def tokens_for(self, bottle_id: str) -> dict[str, str]:
@@ -188,6 +193,62 @@ class Orchestrator:
# The orchestrator owns the single DB *and* the live policy, so operator
# decisions are applied here, server-side, and reached over HTTP by the
# host TUI (no direct-DB access, one path for every backend).
#
# The *agent* half of the queue (queue a proposal, poll for its response)
# is served here too (PRD 0070): the data plane no longer opens the DB, so
# supervise_server / egress / git-gate reach the queue over the control
# plane. Both are keyed on the caller's orchestrator-resolved bottle id —
# never a caller-supplied slug — so a bottle can only queue or read its own
# proposals even if the data plane is compromised.
def supervise_queue_proposal(
self, bottle_id: str, *, tool: str, proposed_file: str, justification: str,
) -> str:
"""Queue an agent-side proposal under `bottle_id` and return its id.
`bottle_id` is the caller resolved from `(source_ip, identity_token)` by
the control plane, so the proposal is attributed to the calling bottle,
not to anything the (possibly hostile) data plane asserts. The
proposed-file self-hash is computed here so the agent never supplies it."""
proposal = Proposal.new(
bottle_slug=bottle_id,
tool=tool,
proposed_file=proposed_file,
justification=justification,
)
self._supervisor.write_proposal(proposal)
return proposal.id
def supervise_poll_response(self, bottle_id: str, proposal_id: str) -> dict[str, object]:
"""Non-blocking, **idempotent** poll of one of `bottle_id`'s proposals.
Returns `{"status": ...}`: a terminal decision (`approved`/`modified`/
`rejected`, with `notes` + `final_file`) once the operator has responded,
`pending` while it's still queued undecided, or `unknown` when no such
queued proposal exists for this bottle (wrong id, or already archived).
Poll does **not** archive a decided proposal keeps returning the same
decision so a client whose connection drops after the decision but before
it consumes the response still gets it on retry (issue #469 review). A
decided proposal is already excluded from the operator's pending list (a
response row exists), so it doesn't linger there; the row itself is reaped
when the bottle is torn down / reconciled.
Reads are scoped to `bottle_id` (the queue key), so a caller can never
read another bottle's proposal even with a guessed id."""
try:
response = self._supervisor.read_response(bottle_id, proposal_id)
except FileNotFoundError:
try:
self._supervisor.read_proposal(bottle_id, proposal_id)
except FileNotFoundError:
return {"status": POLL_STATUS_UNKNOWN}
return {"status": POLL_STATUS_PENDING}
return {
"status": response.status,
"notes": response.notes,
"final_file": response.final_file,
}
def supervise_pending(self) -> list[dict[str, object]]:
"""All pending proposals across bottles, FIFO, as JSON dicts
@@ -198,7 +259,7 @@ class Orchestrator:
orchestrator-assigned bottle_id, which is opaque to an operator). The
CLI renders the label but still responds against `bottle_slug`."""
out: list[dict[str, object]] = []
for p in list_all_pending_proposals():
for p in self._supervisor.list_all_pending_proposals():
d = p.to_dict()
d["bottle_label"] = self._label_for(p.bottle_slug)
out.append(d)
@@ -261,7 +322,7 @@ class Orchestrator:
if status is None:
return False, f"unknown decision {decision!r}"
try:
proposal = read_proposal(bottle_slug, proposal_id)
proposal = self._supervisor.read_proposal(bottle_slug, proposal_id)
except FileNotFoundError:
return False, "no such proposal"
@@ -277,19 +338,22 @@ class Orchestrator:
diff_before, diff_after = rec.policy, new_policy
self.set_policy(rec.bottle_id, new_policy)
write_response(bottle_slug, Response(
self._supervisor.write_response(bottle_slug, Response(
proposal_id=proposal_id, status=status, notes=notes, final_file=final_file,
))
component = COMPONENT_FOR_TOOL.get(proposal.tool)
if component is not None:
write_audit_entry(AuditEntry(
self._supervisor.write_audit_entry(AuditEntry(
timestamp=datetime.now(timezone.utc).isoformat(),
bottle_slug=bottle_slug,
component=component,
operator_action=status,
operator_notes=notes,
justification=proposal.justification,
diff=render_diff(diff_before, diff_after, label=component),
diff=render_diff(
diff_before, diff_after, label=component,
before_title="current", after_label="proposed",
),
))
return True, ""
@@ -302,7 +366,7 @@ class Orchestrator:
value with *env_var_secret*, and restores ``_tokens[bottle_id]``.
Returns True on success, False when no stored secrets exist for this
bottle or decryption fails (wrong key / corrupt data)."""
from .secret_store import decrypt_value
from .store.secret_store import decrypt_value
encrypted = self.registry.get_agent_secrets(bottle_id)
if not encrypted:
return False
@@ -315,22 +379,15 @@ class Orchestrator:
# --- consolidated gateway ----------------------------------------------
def ensure_gateway(self) -> None:
"""Ensure the single per-host gateway is built and up (idempotent).
No-op when no gateway is configured."""
if self._gateway is not None:
self._gateway.ensure_built()
self._gateway.ensure_running()
def gateway_status(self) -> dict[str, object]:
"""Report the shared gateway for the control plane / console."""
if self._gateway is None:
return {"configured": False}
return {
"configured": True,
"name": self._gateway.name,
"running": self._gateway.is_running(),
}
"""Report the shared gateway for the control plane / console.
The orchestrator no longer owns a standalone gateway lifecycle the
consolidated flow runs the gateway data plane inside the per-host infra
container/VM (see `backend/*/gateway`), so this reports `configured:
false`. Retained for the documented `GET /gateway` control-plane
contract."""
return {"configured": False}
__all__ = ["Orchestrator"]
+10
View File
@@ -0,0 +1,10 @@
"""Orchestrator-owned SQLite stores.
The stores whose tables only the orchestrator (control plane) opens: the
per-host bottle `registry_store`, the supervise proposal/response `queue_store`,
the agent-secret `secret_store`, and the orchestrator `config_store`. They build
on the shared `DbStore` / `migrations` base in `bot_bottle.store`.
Callers import the concrete module directly, e.g.
`from bot_bottle.orchestrator.store.queue_store import QueueStore`.
"""
@@ -11,9 +11,9 @@ import os
import sqlite3
from pathlib import Path
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..paths import host_db_path
from ...store.db_store import DbStore
from ...store.migrations import TableMigrations
from ...paths import host_db_path
TEARDOWN_TIMEOUT_ENV = "BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS"
DEFAULT_TEARDOWN_TIMEOUT_SECONDS = 30.0
@@ -6,16 +6,10 @@ import os
import sqlite3
from pathlib import Path
try:
from .supervise_types import Proposal, Response
from .paths import host_db_path
from .db_store import DbStore
from .migrations import TableMigrations
except ImportError:
from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from paths import 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
from ...supervisor.types import Proposal, Response
from ...paths import host_db_path
from ...store.db_store import DbStore
from ...store.migrations import TableMigrations
class QueueStore(DbStore):
@@ -173,23 +167,21 @@ class QueueStore(DbStore):
raise FileNotFoundError(proposal_id)
return self._row_to_response(row)
def archive_proposal(self, proposal_id: str) -> None:
def archive_all(self) -> None:
"""Archive every proposal + response for this queue key. Used to reap a
bottle's supervise records when it is torn down or reconciled away —
the retention path for a client that received a decision but died before
acknowledging it. Idempotent."""
if not self.db_path.is_file():
return
with self._connection() as conn:
conn.execute(
"""
UPDATE supervise_proposals SET archived = 1
WHERE queue_key = ? AND id = ?
""",
(self.queue_key, proposal_id),
"UPDATE supervise_proposals SET archived = 1 WHERE queue_key = ?",
(self.queue_key,),
)
conn.execute(
"""
UPDATE supervise_responses SET archived = 1
WHERE queue_key = ? AND proposal_id = ?
""",
(self.queue_key, proposal_id),
"UPDATE supervise_responses SET archived = 1 WHERE queue_key = ?",
(self.queue_key,),
)
@staticmethod
@@ -36,9 +36,9 @@ from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..paths import host_db_path
from ...store.db_store import DbStore
from ...store.migrations import TableMigrations
from ...paths import host_db_path
# 256 bits of urandom, URL-safe — unguessable per-bottle identity token.
IDENTITY_TOKEN_BYTES = 32
@@ -1,34 +1,34 @@
"""Singleton manager for all bot-bottle SQLite stores (PRD 0013)."""
"""The orchestrator's store manager (PRD 0013 / 0070).
One store manager per service. Post-PRD-0070 the orchestrator is the sole
opener of `bot-bottle.db`, so it owns migrating every table in it: the supervise
`queue_store` (local to `orchestrator.store`) plus the `audit_store` and
`config_store` from the shared `bot_bottle.store` base. The data plane never
touches these it reaches state over the control-plane RPC.
"""
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
from .queue_store import QueueStore
from ...store.audit_store import AuditStore
from ...store.config_store import ConfigStore
_instance: StoreManager | None = None
class StoreManager:
"""Owns db_path and delegates migrate/is_migrated across all stores.
"""Owns db_path and delegates migrate/is_migrated across the orchestrator's
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
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:
try:
from .paths import host_db_path
except ImportError:
from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from ...paths import host_db_path
db_path = host_db_path()
self.db_path = db_path
+91
View File
@@ -0,0 +1,91 @@
"""The orchestrator's supervise service.
`Supervisor` is the service object the `Orchestrator` owns and calls: it wraps
the supervise queue + audit stores (queue proposals, record operator responses,
write audit entries, stage the DB at launch). Binding those operations to one
object optionally scoped to a `db_path` makes the orchestrator's supervise
dependency explicit and injectable in tests.
For convenience this package also re-exports the neutral vocabulary
(`bot_bottle.supervisor`'s types + `SupervisePlan`), so orchestrator-side
callers import from one place. (Generic helpers like `render_diff` /
`sha256_hex` live in `bot_bottle.util`.) The data plane must NOT import this
package it imports
`bot_bottle.supervisor` (neutral) directly and reaches the queue over the
control-plane RPC.
"""
from __future__ import annotations
from pathlib import Path
from ..supervisor.types import * # noqa: F401,F403 — re-export the neutral vocabulary
from ..supervisor.plan import SupervisePlan
from .store.store_manager import StoreManager
from .store.queue_store import QueueStore
from ..store.audit_store import AuditStore
from ..supervisor.types import AuditEntry, Proposal, Response
class Supervisor:
"""Host-side supervise service: the queue + audit I/O and launch-time DB
staging the orchestrator drives. Stateless apart from an optional `db_path`
(None the host DB, resolved per store as before), so a test can point a
`Supervisor` at a temp database."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path = db_path
def _queue(self, queue_key: str) -> QueueStore:
return QueueStore(queue_key, self._db_path)
def _audit(self) -> AuditStore:
return AuditStore(self._db_path)
# --- Queue I/O ---------------------------------------------------------
def write_proposal(self, proposal: Proposal) -> Path:
"""Persist `proposal` in the queue database, mode 0o600."""
return self._queue(proposal.bottle_slug).write_proposal(proposal)
def read_proposal(self, bottle_slug: str, proposal_id: str) -> Proposal:
return self._queue(bottle_slug).read_proposal(proposal_id)
def list_pending_proposals(self, bottle_slug: str) -> list[Proposal]:
"""A single bottle's undecided proposals, FIFO by arrival."""
return self._queue(bottle_slug).list_pending_proposals()
def list_all_pending_proposals(self) -> list[Proposal]:
"""All pending proposals across bottles, sorted FIFO."""
return self._queue("").list_all_pending_proposals()
def write_response(self, bottle_slug: str, response: Response) -> Path:
return self._queue(bottle_slug).write_response(response)
def read_response(self, bottle_slug: str, proposal_id: str) -> Response:
return self._queue(bottle_slug).read_response(proposal_id)
def archive_all_proposals(self, bottle_slug: str) -> None:
"""Archive every proposal + response for `bottle_slug` (bottle teardown
/ reconcile cleanup). Idempotent."""
self._queue(bottle_slug).archive_all()
# --- Audit log ---------------------------------------------------------
def write_audit_entry(self, entry: AuditEntry) -> Path:
"""Append `entry` to the host supervise audit table."""
return self._audit().write_audit_entry(entry)
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
"""Load all audit entries for the given component+slug."""
return self._audit().read_audit_entries(component, slug)
# --- Launch-time staging ----------------------------------------------
def prepare(self, slug: str, stage_dir: Path) -> SupervisePlan:
"""Stage the host database and return the plan. Called by the backend at
launch; `internal_network` is set by the launch step before `.start`."""
del stage_dir
mgr = StoreManager(self._db_path) if self._db_path else StoreManager.instance()
mgr.migrate()
return SupervisePlan(slug=slug, db_path=mgr.db_path)
+100
View File
@@ -0,0 +1,100 @@
"""Role-scoped control-plane credentials (issue #469 review follow-up).
The control plane no longer trusts a single shared bearer secret for every
route. Instead the orchestrator holds a *signing key* and issues short,
HMAC-signed tokens (compact HS256 JWTs) that embed a **role** naming the kind
of caller:
* ``gateway`` the data plane (egress / git-gate / supervise). Restricted to
the agent-facing routes it actually needs (``/resolve``,
``/supervise/propose``, ``/supervise/poll``).
* ``cli`` the host operator / launcher. Full access to the mutating and
operator routes (launch/teardown, policy, ``/supervise/respond``, ).
Only the orchestrator (and the host CLI, which shares the host trust domain)
holds the signing key; the gateway is handed a pre-minted ``gateway`` token it
cannot rewrite into a ``cli`` token. So a compromised data-plane process can no
longer approve its own supervise proposals or drive operator routes it can
only present the ``gateway`` role it was issued (the control plane rejects it
on operator routes with 403).
Stdlib-only (HMAC-SHA256 over a JSON payload); no JWT dependency the project
carries no runtime pip deps. Tokens are **signed, not encrypted** (the role is
not a secret) and **long-lived** (parity with the static token they replace;
the security win is the unforgeable role claim, not rotation).
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
ROLE_GATEWAY = "gateway"
ROLE_CLI = "cli"
ROLES: frozenset[str] = frozenset({ROLE_GATEWAY, ROLE_CLI})
_ALG = "HS256"
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _b64url_decode(text: str) -> bytes:
padded = text + "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _sign(secret: str, signing_input: str) -> str:
mac = hmac.new(secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256)
return _b64url_encode(mac.digest())
# The fixed, canonical JOSE header — the same for every token we mint.
_HEADER_SEGMENT = _b64url_encode(
json.dumps({"alg": _ALG, "typ": "JWT"}, separators=(",", ":")).encode("utf-8")
)
def mint(role: str, secret: str) -> str:
"""A compact HS256 token asserting `role`, signed with `secret`.
Raises ValueError for an unknown role (mint only what the control plane will
accept) or an empty signing key (an unsigned credential is never valid)."""
if role not in ROLES:
raise ValueError(f"unknown control-plane role {role!r}")
if not secret:
raise ValueError("cannot mint a control-plane token without a signing key")
payload = _b64url_encode(json.dumps({"role": role}, separators=(",", ":")).encode("utf-8"))
signing_input = f"{_HEADER_SEGMENT}.{payload}"
return f"{signing_input}.{_sign(secret, signing_input)}"
def verify(token: str, secret: str) -> str | None:
"""The role a valid `token` carries, or None if it is malformed, wrongly
signed, or names an unknown role. Constant-time signature check; rejects any
header whose alg isn't HS256 (no alg-confusion / `none`)."""
if not token or not secret:
return None
parts = token.split(".")
if len(parts) != 3:
return None
header_b64, payload_b64, sig = parts
expected = _sign(secret, f"{header_b64}.{payload_b64}")
if not hmac.compare_digest(sig, expected):
return None
try:
header = json.loads(_b64url_decode(header_b64))
payload = json.loads(_b64url_decode(payload_b64))
except (ValueError, binascii.Error):
return None
if not isinstance(header, dict) or header.get("alg") != _ALG:
return None
role = payload.get("role") if isinstance(payload, dict) else None
return role if isinstance(role, str) and role in ROLES else None
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]

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