Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dbf1780b4 | |||
| ff4da6f41e | |||
| 3bb90da11c | |||
| 0146450951 | |||
| ecaf23cdb5 | |||
| 6e46a9b191 | |||
| a59e495faa | |||
| b8818948a0 | |||
| b09952045a | |||
| de192359ee | |||
| 7d9933edc0 | |||
| 2bc9ef8ec0 | |||
| 47b6bead69 | |||
| 15ecada022 | |||
| e2222bd96b |
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
BottleImages,
|
BottleImages,
|
||||||
BottlePlan,
|
BottlePlan,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
|
EnumerationError,
|
||||||
ExecResult,
|
ExecResult,
|
||||||
)
|
)
|
||||||
from .selection import (
|
from .selection import (
|
||||||
@@ -59,6 +60,7 @@ _LAZY_MODULES: dict[str, str] = {
|
|||||||
"BottleImages": "base",
|
"BottleImages": "base",
|
||||||
"BottleBackend": "base",
|
"BottleBackend": "base",
|
||||||
"BackendStatus": "base",
|
"BackendStatus": "base",
|
||||||
|
"EnumerationError": "base",
|
||||||
"get_bottle_backend": "selection",
|
"get_bottle_backend": "selection",
|
||||||
"known_backend_names": "selection",
|
"known_backend_names": "selection",
|
||||||
"has_backend": "selection",
|
"has_backend": "selection",
|
||||||
@@ -100,6 +102,7 @@ __all__ = [
|
|||||||
"BottlePlan",
|
"BottlePlan",
|
||||||
"BottleSpec",
|
"BottleSpec",
|
||||||
"ExecResult",
|
"ExecResult",
|
||||||
|
"EnumerationError",
|
||||||
"CommitCancelled",
|
"CommitCancelled",
|
||||||
"Freezer",
|
"Freezer",
|
||||||
"get_freezer",
|
"get_freezer",
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class BackendStatus(enum.IntEnum):
|
|||||||
READY = 0
|
READY = 0
|
||||||
|
|
||||||
|
|
||||||
|
class EnumerationError(RuntimeError):
|
||||||
|
"""A backend could not produce an authoritative live-resource snapshot."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BottleSpec:
|
class BottleSpec:
|
||||||
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
|
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
|
from ..base import EnumerationError
|
||||||
|
|
||||||
|
|
||||||
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
||||||
@@ -52,19 +53,20 @@ def slug_from_compose_project(project: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def list_compose_projects(
|
def list_compose_projects(
|
||||||
*, include_stopped: bool = True, warn_on_error: bool = True,
|
*,
|
||||||
|
include_stopped: bool = True,
|
||||||
|
warn_on_error: bool = True,
|
||||||
|
raise_on_error: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""All compose project names starting with `bot-bottle-`.
|
"""All compose project names starting with `bot-bottle-`.
|
||||||
`include_stopped=True` (default) runs `docker compose ls --all`
|
`include_stopped=True` (default) runs `docker compose ls --all`
|
||||||
so exited projects appear too; pass False to get only projects
|
so exited projects appear too; pass False to get only projects
|
||||||
with at least one running container.
|
with at least one running container.
|
||||||
|
|
||||||
Returns [] on docker daemon errors or malformed output rather
|
Best-effort callers get ``[]`` on Docker errors or malformed output.
|
||||||
than raising — callers should treat the empty list as "no
|
Enumeration callers pass ``raise_on_error=True`` so a failed query is not
|
||||||
projects discoverable", not "no projects exist". `warn_on_error`
|
reported as an authoritative empty result.
|
||||||
stays true for explicit operator commands like cleanup, but active
|
"""
|
||||||
discovery paths set it false so dashboard refreshes don't spam
|
|
||||||
stderr while Docker Desktop is stopped."""
|
|
||||||
argv = ["docker", "compose", "ls", "--format", "json"]
|
argv = ["docker", "compose", "ls", "--format", "json"]
|
||||||
if include_stopped:
|
if include_stopped:
|
||||||
argv.insert(3, "--all")
|
argv.insert(3, "--all")
|
||||||
@@ -72,19 +74,27 @@ def list_compose_projects(
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
argv, capture_output=True, text=True, check=False,
|
argv, capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError as exc:
|
||||||
# docker binary not on PATH — same shape as a daemon-down
|
if raise_on_error:
|
||||||
# error from the caller's POV: no projects discoverable.
|
raise EnumerationError(
|
||||||
|
"docker compose ls failed: docker not found"
|
||||||
|
) from exc
|
||||||
return []
|
return []
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
message = f"docker compose ls failed: {result.stderr.strip()}"
|
||||||
|
if raise_on_error:
|
||||||
|
raise EnumerationError(message)
|
||||||
if warn_on_error:
|
if warn_on_error:
|
||||||
warn(f"docker compose ls failed: {result.stderr.strip()}")
|
warn(message)
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
projects = json.loads(result.stdout or "[]")
|
projects = json.loads(result.stdout or "[]")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
|
message = f"docker compose ls returned malformed JSON: {e}"
|
||||||
|
if raise_on_error:
|
||||||
|
raise EnumerationError(message) from e
|
||||||
if warn_on_error:
|
if warn_on_error:
|
||||||
warn(f"docker compose ls returned malformed JSON: {e}")
|
warn(message)
|
||||||
return []
|
return []
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
for p in projects:
|
for p in projects:
|
||||||
@@ -97,7 +107,10 @@ def list_compose_projects(
|
|||||||
|
|
||||||
|
|
||||||
def list_active_slugs(
|
def list_active_slugs(
|
||||||
*, include_stopped: bool = False, warn_on_error: bool = True,
|
*,
|
||||||
|
include_stopped: bool = False,
|
||||||
|
warn_on_error: bool = True,
|
||||||
|
raise_on_error: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Slugs (project name minus prefix) of currently-running
|
"""Slugs (project name minus prefix) of currently-running
|
||||||
bottles. Used by the dashboard's operator-edit verbs to choose
|
bottles. Used by the dashboard's operator-edit verbs to choose
|
||||||
@@ -108,6 +121,7 @@ def list_active_slugs(
|
|||||||
for p in list_compose_projects(
|
for p in list_compose_projects(
|
||||||
include_stopped=include_stopped,
|
include_stopped=include_stopped,
|
||||||
warn_on_error=warn_on_error,
|
warn_on_error=warn_on_error,
|
||||||
|
raise_on_error=raise_on_error,
|
||||||
)
|
)
|
||||||
) if slug
|
) if slug
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ def _network_container_ips(network: str) -> list[str]:
|
|||||||
"docker", "network", "inspect", "--format",
|
"docker", "network", "inspect", "--format",
|
||||||
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
||||||
])
|
])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = proc.stderr.strip() or f"exit {proc.returncode}"
|
||||||
|
raise ConsolidatedLaunchError(
|
||||||
|
f"could not inspect addresses on gateway network {network}: {detail}"
|
||||||
|
)
|
||||||
ips: list[str] = []
|
ips: list[str] = []
|
||||||
for entry in proc.stdout.split():
|
for entry in proc.stdout.split():
|
||||||
ips.append(entry.split("/", 1)[0])
|
ips.append(entry.split("/", 1)[0])
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Active-agent enumeration for the docker backend.
|
"""Active-agent enumeration for the docker backend.
|
||||||
|
|
||||||
Returns `ActiveAgent` records the CLI `active` command and the
|
Returns `ActiveAgent` records the CLI `active` command and the
|
||||||
dashboard agents pane consume. Empty when docker isn't reachable
|
dashboard agents pane consume. Docker query failures raise rather
|
||||||
— gated by `has_backend('docker')` at the cross-backend caller
|
than masquerading as an authoritative empty result.
|
||||||
so this module trusts that docker is available when called.
|
|
||||||
|
|
||||||
The parser (`_parse_services_by_project`) is exposed for direct
|
The parser (`_parse_services_by_project`) is exposed for direct
|
||||||
unit testing; the docker `docker ps` invocation is in
|
unit testing; the docker `docker ps` invocation is in
|
||||||
@@ -13,17 +12,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent, EnumerationError
|
||||||
from ...bottle_state import read_metadata
|
from ...bottle_state import read_metadata
|
||||||
from .compose import compose_project_name, list_active_slugs
|
from .compose import compose_project_name, list_active_slugs
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
"""All currently-running docker-backed agents. Caller is
|
"""All currently-running docker-backed agents."""
|
||||||
responsible for gating on `has_backend('docker')` if it
|
slugs = list_active_slugs(
|
||||||
matters; if docker is missing the `docker ps` call below
|
include_stopped=False,
|
||||||
returns an empty list silently."""
|
warn_on_error=False,
|
||||||
slugs = list_active_slugs(include_stopped=False, warn_on_error=False)
|
raise_on_error=True,
|
||||||
|
)
|
||||||
if not slugs:
|
if not slugs:
|
||||||
return []
|
return []
|
||||||
services_by_project = _query_services_by_project()
|
services_by_project = _query_services_by_project()
|
||||||
@@ -74,8 +74,8 @@ def _query_services_by_project() -> dict[str, set[str]]:
|
|||||||
],
|
],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError as exc:
|
||||||
return {}
|
raise EnumerationError("docker ps failed: docker not found") from exc
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return {}
|
raise EnumerationError(f"docker ps failed: {r.stderr.strip()}")
|
||||||
return _parse_services_by_project(r.stdout or "")
|
return _parse_services_by_project(r.stdout or "")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import info
|
from ...log import info
|
||||||
|
from .. import EnumerationError
|
||||||
from . import util
|
from . import util
|
||||||
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||||
|
|
||||||
@@ -62,12 +63,23 @@ def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
|||||||
* ``orphan_pids`` — firecracker pids whose run dir no longer exists
|
* ``orphan_pids`` — firecracker pids whose run dir no longer exists
|
||||||
(a lingering VMM to kill).
|
(a lingering VMM to kill).
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
try:
|
||||||
["pgrep", "-a", "firecracker"],
|
result = subprocess.run(
|
||||||
capture_output=True, text=True, check=False,
|
["pgrep", "-a", "firecracker"],
|
||||||
)
|
capture_output=True, text=True, check=False,
|
||||||
if result.returncode != 0:
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise EnumerationError(
|
||||||
|
f"could not enumerate Firecracker processes: {exc}"
|
||||||
|
) from exc
|
||||||
|
if result.returncode == 1:
|
||||||
|
# pgrep's documented "no processes matched" result.
|
||||||
return set(), []
|
return set(), []
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or "").strip() or f"exit {result.returncode}"
|
||||||
|
raise EnumerationError(
|
||||||
|
f"could not enumerate Firecracker processes: {detail}"
|
||||||
|
)
|
||||||
live: set[str] = set()
|
live: set[str] = set()
|
||||||
orphan_pids: list[int] = []
|
orphan_pids: list[int] = []
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
"""Active-agent enumeration for the Firecracker backend.
|
"""Active-agent enumeration for the Firecracker backend.
|
||||||
|
|
||||||
The backend is disabled during the companion-container removal (#385) — it can't
|
Running bottles are the Firecracker processes whose ``--config-file`` points
|
||||||
launch bottles, so there are none to enumerate. Real enumeration returns
|
at an existing per-bottle run directory. The same authoritative process scan
|
||||||
with the backend's consolidated relaunch (#354).
|
protects cleanup from deleting live VMs; operational scan failures propagate
|
||||||
|
as ``EnumerationError`` instead of masquerading as an empty host.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...bottle_state import read_metadata
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
from .cleanup import live_run_dirs
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
return []
|
out: list[ActiveAgent] = []
|
||||||
|
for run_dir in live_run_dirs():
|
||||||
|
slug = run_dir.name
|
||||||
|
metadata = read_metadata(slug)
|
||||||
|
out.append(ActiveAgent(
|
||||||
|
backend_name="firecracker",
|
||||||
|
slug=slug,
|
||||||
|
agent_name=metadata.agent_name if metadata else "?",
|
||||||
|
started_at=metadata.started_at if metadata else "",
|
||||||
|
# Firecracker uses the shared gateway, so there are no
|
||||||
|
# per-bottle gateway service containers to report.
|
||||||
|
services=(),
|
||||||
|
label=metadata.label if metadata else "",
|
||||||
|
color=metadata.color if metadata else "",
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
from ...bottle_state import read_metadata
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent, EnumerationError
|
||||||
from .infra import INFRA_NAME, ORCHESTRATOR_NAME
|
from .infra import INFRA_NAME, ORCHESTRATOR_NAME
|
||||||
|
|
||||||
# The name every agent container carries: `bot-bottle-<slug>`. Exported
|
# The name every agent container carries: `bot-bottle-<slug>`. Exported
|
||||||
@@ -20,17 +20,18 @@ CONTAINER_NAME_PREFIX = "bot-bottle-"
|
|||||||
_INFRA_NAMES = frozenset({INFRA_NAME, ORCHESTRATOR_NAME})
|
_INFRA_NAMES = frozenset({INFRA_NAME, ORCHESTRATOR_NAME})
|
||||||
|
|
||||||
|
|
||||||
class EnumerationError(RuntimeError):
|
|
||||||
"""container list failed; the resulting live set is not authoritative."""
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
result = subprocess.run(
|
try:
|
||||||
["container", "list", "--quiet"],
|
result = subprocess.run(
|
||||||
capture_output=True,
|
["container", "list", "--quiet"],
|
||||||
text=True,
|
capture_output=True,
|
||||||
check=False,
|
text=True,
|
||||||
)
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise EnumerationError(
|
||||||
|
"container list failed: container CLI not found"
|
||||||
|
) from exc
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise EnumerationError(
|
raise EnumerationError(
|
||||||
f"container list failed: "
|
f"container list failed: "
|
||||||
|
|||||||
@@ -389,19 +389,9 @@ class EgressAddon:
|
|||||||
self._passthrough_conns.discard(conn_id)
|
self._passthrough_conns.discard(conn_id)
|
||||||
|
|
||||||
async def request(self, flow: http.HTTPFlow) -> None:
|
async def request(self, flow: http.HTTPFlow) -> None:
|
||||||
|
config, slug, env = self._request_context(flow)
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
# Reuse the context stashed by http_connect for HTTPS flows (one
|
|
||||||
# orchestrator round-trip per connection). Plain-HTTP flows have no
|
|
||||||
# prior CONNECT stash, so resolve now and stash for response/websocket.
|
|
||||||
meta = getattr(flow, "metadata", None)
|
|
||||||
if isinstance(meta, dict) and _FLOW_CTX_KEY in meta:
|
|
||||||
config, slug, env = meta[_FLOW_CTX_KEY]
|
|
||||||
self._request_token(flow) # strip identity headers; token already resolved
|
|
||||||
else:
|
|
||||||
config, slug, env = self._resolve_flow(flow)
|
|
||||||
self._stash_flow_ctx(flow, config, slug, env)
|
|
||||||
|
|
||||||
# Introspection ("_egress.local/allowlist") reports the calling bottle's
|
# Introspection ("_egress.local/allowlist") reports the calling bottle's
|
||||||
# own resolved routes — served after resolution so it reflects this
|
# own resolved routes — served after resolution so it reflects this
|
||||||
# bottle's policy, not a stale global.
|
# bottle's policy, not a stale global.
|
||||||
@@ -422,6 +412,29 @@ class EgressAddon:
|
|||||||
# the path/query the git checks below rely on.
|
# the path/query the git checks below rely on.
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
|
if not self._allow_git_request(flow, config, request_path, query):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._apply_route_policy(flow, config, route, request_path, env)
|
||||||
|
|
||||||
|
def _request_context(
|
||||||
|
self, flow: http.HTTPFlow,
|
||||||
|
) -> tuple[Config, str, "typing.Mapping[str, str]"]:
|
||||||
|
"""Resolve one bottle context, reusing the HTTPS CONNECT snapshot."""
|
||||||
|
meta = getattr(flow, "metadata", None)
|
||||||
|
if isinstance(meta, dict) and _FLOW_CTX_KEY in meta:
|
||||||
|
config, slug, env = meta[_FLOW_CTX_KEY]
|
||||||
|
self._request_token(flow)
|
||||||
|
return config, slug, env
|
||||||
|
config, slug, env = self._resolve_flow(flow)
|
||||||
|
self._stash_flow_ctx(flow, config, slug, env)
|
||||||
|
return config, slug, env
|
||||||
|
|
||||||
|
def _allow_git_request(
|
||||||
|
self, flow: http.HTTPFlow, config: Config,
|
||||||
|
request_path: str, query: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply the HTTPS Git push/fetch boundary before general routing."""
|
||||||
if is_git_push_request(request_path, query):
|
if is_git_push_request(request_path, query):
|
||||||
self._block(
|
self._block(
|
||||||
flow,
|
flow,
|
||||||
@@ -430,20 +443,20 @@ class EgressAddon:
|
|||||||
"git-gate's pre-receive hook).",
|
"git-gate's pre-receive hook).",
|
||||||
ctx=self._req_ctx(flow),
|
ctx=self._req_ctx(flow),
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
if not is_git_fetch_request(request_path, query):
|
||||||
if is_git_fetch_request(request_path, query):
|
return True
|
||||||
git_decision = decide_git_fetch(
|
git_decision = decide_git_fetch(config.routes, flow.request.pretty_host)
|
||||||
config.routes, flow.request.pretty_host,
|
if git_decision.action != "block":
|
||||||
)
|
return True
|
||||||
if git_decision.action == "block":
|
self._block(flow, git_decision.reason, ctx=self._req_ctx(flow))
|
||||||
self._block(
|
return False
|
||||||
flow,
|
|
||||||
git_decision.reason,
|
|
||||||
ctx=self._req_ctx(flow),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
def _apply_route_policy(
|
||||||
|
self, flow: http.HTTPFlow, config: Config, route: Route | None,
|
||||||
|
request_path: str, env: "typing.Mapping[str, str]",
|
||||||
|
) -> None:
|
||||||
|
"""Strip agent auth, evaluate the route, then inject gateway auth."""
|
||||||
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
||||||
# are caught above; the route may inject gateway-owned auth below.
|
# are caught above; the route may inject gateway-owned auth below.
|
||||||
# Routes with preserve_auth=True pass the header through as-is so the
|
# Routes with preserve_auth=True pass the header through as-is so the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Run the orchestrator control plane as a plain process (PRD 0070 dev-harness).
|
"""Run the orchestrator control plane as a plain process (PRD 0070 dev-harness).
|
||||||
|
|
||||||
python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH]
|
BOT_BOTTLE_ORCHESTRATOR_TOKEN=<signing-key> \
|
||||||
|
python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH]
|
||||||
|
|
||||||
The PRD sequences the orchestrator as a plain-process dev-harness first, so
|
The PRD sequences the orchestrator as a plain-process dev-harness first, so
|
||||||
the consolidation core (registry + attribution + HTTP control plane + live
|
the consolidation core (registry + attribution + HTTP control plane + live
|
||||||
@@ -16,12 +17,13 @@ import secrets
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import log
|
from .. import log
|
||||||
from .store.store_manager import StoreManager
|
from ..trust_domain import CONTROL_PLANE
|
||||||
from .broker import LaunchBroker, StubBroker
|
from .broker import LaunchBroker, StubBroker
|
||||||
from .server import make_server
|
|
||||||
from .docker_broker import DockerBroker
|
from .docker_broker import DockerBroker
|
||||||
from .store.registry_store import RegistryStore, default_db_path
|
from .server import make_server
|
||||||
from .service import OrchestratorCore
|
from .service import OrchestratorCore
|
||||||
|
from .store.store_manager import StoreManager
|
||||||
|
from .store.registry_store import RegistryStore, default_db_path
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
@@ -38,6 +40,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
help="launch broker: 'stub' records requests; 'docker' runs containers",
|
help="launch broker: 'stub' records requests; 'docker' runs containers",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
if not CONTROL_PLANE.key_from_env():
|
||||||
|
log.die(
|
||||||
|
f"{CONTROL_PLANE.key_env} is required; refusing to start the "
|
||||||
|
"orchestrator without caller authentication"
|
||||||
|
)
|
||||||
|
|
||||||
registry = RegistryStore(args.db)
|
registry = RegistryStore(args.db)
|
||||||
registry.migrate()
|
registry.migrate()
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ import http.server
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
@@ -80,6 +82,9 @@ Json = dict[str, object]
|
|||||||
# token at all, and a compromised gateway holds only `gateway` — neither can
|
# token at all, and a compromised gateway holds only `gateway` — neither can
|
||||||
# drive the operator routes (approve proposals, rewrite policy, read tokens).
|
# drive the operator routes (approve proposals, rewrite policy, read tokens).
|
||||||
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
|
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
|
||||||
|
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 10.0
|
||||||
|
MAX_REQUEST_THREADS = 32
|
||||||
|
|
||||||
# The routes the data plane (role `gateway`) is allowed to reach — exactly the
|
# The routes the data plane (role `gateway`) is allowed to reach — exactly the
|
||||||
# per-request lookups PolicyResolver makes. Every other authenticated route is
|
# per-request lookups PolicyResolver makes. Every other authenticated route is
|
||||||
@@ -116,9 +121,8 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
no I/O beyond the orchestrator — so it is fully testable without a socket.
|
no I/O beyond the orchestrator — so it is fully testable without a socket.
|
||||||
|
|
||||||
`role` is the caller's verified control-plane role (`gateway` or `cli`), or
|
`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
|
None for an unauthenticated request. Every route except `GET /health`
|
||||||
configured — see `OrchestratorServer`) passes `cli`. Every route except
|
requires a role: a missing role is 401, and a role that
|
||||||
`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
|
doesn't cover the route is 403 — so a `gateway` data-plane token can reach
|
||||||
`/resolve` + `/supervise/{propose,poll}` but not the operator routes
|
`/resolve` + `/supervise/{propose,poll}` but not the operator routes
|
||||||
(rewrite policy, read injected tokens, approve its own supervise proposals).
|
(rewrite policy, read injected tokens, approve its own supervise proposals).
|
||||||
@@ -372,10 +376,33 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
plane down for the caller."""
|
plane down for the caller."""
|
||||||
server = self.server
|
server = self.server
|
||||||
assert isinstance(server, OrchestratorServer)
|
assert isinstance(server, OrchestratorServer)
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
body = self.rfile.read(length) if length > 0 else b""
|
|
||||||
role = server.role_for(self.headers.get(ORCHESTRATOR_AUTH_HEADER, ""))
|
role = server.role_for(self.headers.get(ORCHESTRATOR_AUTH_HEADER, ""))
|
||||||
|
route = urlsplit(self.path).path.rstrip("/") or "/"
|
||||||
|
if not (method == "GET" and route == "/health") and role is None:
|
||||||
|
self._write_json(
|
||||||
|
401, {"error": "control-plane authentication required"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
length_header = self.headers.get("Content-Length")
|
||||||
try:
|
try:
|
||||||
|
length = int(length_header) if length_header is not None else 0
|
||||||
|
except ValueError:
|
||||||
|
self._write_json(400, {"error": "invalid Content-Length"})
|
||||||
|
return
|
||||||
|
if length < 0:
|
||||||
|
self._write_json(400, {"error": "invalid Content-Length"})
|
||||||
|
return
|
||||||
|
if length > MAX_BODY_BYTES:
|
||||||
|
self._write_json(413, {"error": "request body too large"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self.rfile.read(length) if length else b""
|
||||||
|
except (TimeoutError, socket.timeout):
|
||||||
|
self._write_json(408, {"error": "request body read timed out"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status: int
|
||||||
|
payload: Json
|
||||||
status, payload = dispatch(
|
status, payload = dispatch(
|
||||||
server.orchestrator, method, self.path, body, role=role)
|
server.orchestrator, method, self.path, body, role=role)
|
||||||
except Exception as e: # noqa: BLE001 — the control plane must stay up
|
except Exception as e: # noqa: BLE001 — the control plane must stay up
|
||||||
@@ -388,6 +415,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
status, payload = 500, {"error": "internal error"}
|
status, payload = 500, {"error": "internal error"}
|
||||||
|
self._write_json(status, payload)
|
||||||
|
|
||||||
|
def _write_json(self, status: int, payload: Json) -> None:
|
||||||
data = json.dumps(payload).encode()
|
data = json.dumps(payload).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
@@ -414,51 +444,80 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
Holds the per-host control-plane *signing key* (from
|
Holds the per-host control-plane *signing key* (from
|
||||||
`$BOT_BOTTLE_ORCHESTRATOR_TOKEN`, injected by the launcher into the
|
`$BOT_BOTTLE_ORCHESTRATOR_TOKEN`, injected by the launcher into the
|
||||||
orchestrator process only) and verifies each request's role-scoped token
|
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
|
against it. Every route but `/health` requires a valid token whose role
|
||||||
token whose role covers the route; when it is unset the server runs **open**
|
covers the route. Construction fails when the key is absent so a new or
|
||||||
(full `cli` access) and says so loudly at startup — a fail-visible fallback
|
misconfigured launcher cannot accidentally expose an open control plane."""
|
||||||
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
|
daemon_threads = True
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
|
|
||||||
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
address: tuple[str, int],
|
||||||
|
orchestrator: OrchestratorCore,
|
||||||
|
*,
|
||||||
|
signing_key: str,
|
||||||
|
) -> None:
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
# The control-plane trust domain's signing key, as injected into THIS
|
self._signing_key = signing_key.strip()
|
||||||
# (the owning) process by the launcher (#476). Unset → open mode below.
|
|
||||||
self._signing_key = CONTROL_PLANE.key_from_env()
|
|
||||||
if not self._signing_key:
|
if not self._signing_key:
|
||||||
sys.stderr.write(
|
raise ValueError(
|
||||||
"orchestrator: WARNING — no control-plane signing key "
|
"orchestrator control-plane signing key is required; "
|
||||||
f"(${CONTROL_PLANE.key_env}); running WITHOUT caller "
|
"refusing to start without caller authentication"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
self._request_slots = threading.BoundedSemaphore(MAX_REQUEST_THREADS)
|
||||||
super().__init__(address, Handler)
|
super().__init__(address, Handler)
|
||||||
|
|
||||||
|
def get_request(self) -> tuple[socket.socket, typing.Any]:
|
||||||
|
request, client_address = super().get_request()
|
||||||
|
request.settimeout(REQUEST_TIMEOUT_SECONDS)
|
||||||
|
return request, client_address
|
||||||
|
|
||||||
|
def process_request(
|
||||||
|
self, request: typing.Any, client_address: typing.Any,
|
||||||
|
) -> None:
|
||||||
|
# Bound concurrency before ThreadingMixIn creates a worker. Backpressure
|
||||||
|
# stays in the accept loop instead of allocating an unbounded thread per
|
||||||
|
# slow or malicious connection.
|
||||||
|
self._request_slots.acquire()
|
||||||
|
try:
|
||||||
|
super().process_request(request, client_address)
|
||||||
|
except BaseException:
|
||||||
|
self._request_slots.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def process_request_thread(
|
||||||
|
self, request: typing.Any, client_address: typing.Any,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
super().process_request_thread(request, client_address)
|
||||||
|
finally:
|
||||||
|
self._request_slots.release()
|
||||||
|
|
||||||
def role_for(self, presented: str) -> str | None:
|
def role_for(self, presented: str) -> str | None:
|
||||||
"""The role the request is authorized as, or None if unauthenticated.
|
"""The verified caller role, or None for a missing/invalid token."""
|
||||||
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 CONTROL_PLANE.verify(presented, self._signing_key)
|
return CONTROL_PLANE.verify(presented, self._signing_key)
|
||||||
|
|
||||||
|
|
||||||
def make_server(
|
def make_server(
|
||||||
orchestrator: OrchestratorCore, host: str = "127.0.0.1", port: int = 0
|
orchestrator: OrchestratorCore,
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
port: int = 0,
|
||||||
|
*,
|
||||||
|
signing_key: str | None = None,
|
||||||
) -> OrchestratorServer:
|
) -> OrchestratorServer:
|
||||||
"""Build (but do not start) a control-plane server. `port=0` binds an
|
"""Build an authenticated control-plane server.
|
||||||
ephemeral port — read `server.server_address` for the actual one."""
|
|
||||||
return OrchestratorServer((host, port), orchestrator)
|
``signing_key=None`` reads the owning process's injected environment.
|
||||||
|
Empty or missing keys are rejected by :class:`OrchestratorServer`.
|
||||||
|
"""
|
||||||
|
key = CONTROL_PLANE.key_from_env() if signing_key is None else signing_key
|
||||||
|
return OrchestratorServer(
|
||||||
|
(host, port), orchestrator, signing_key=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
|
"dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
|
||||||
"ORCHESTRATOR_AUTH_HEADER",
|
"ORCHESTRATOR_AUTH_HEADER", "MAX_BODY_BYTES",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -366,15 +366,23 @@ class OrchestratorCore:
|
|||||||
value with *env_var_secret*, and restores ``_tokens[bottle_id]``.
|
value with *env_var_secret*, and restores ``_tokens[bottle_id]``.
|
||||||
Returns True on success, False when no stored secrets exist for this
|
Returns True on success, False when no stored secrets exist for this
|
||||||
bottle or decryption fails (wrong key / corrupt data)."""
|
bottle or decryption fails (wrong key / corrupt data)."""
|
||||||
from .store.secret_store import decrypt_value
|
from .store.secret_store import decrypt_value, encrypt_value, is_legacy_blob
|
||||||
encrypted = self.registry.get_agent_secrets(bottle_id)
|
encrypted = self.registry.get_agent_secrets(bottle_id)
|
||||||
if not encrypted:
|
if not encrypted:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self._tokens[bottle_id] = {k: decrypt_value(env_var_secret, v)
|
decrypted = {
|
||||||
for k, v in encrypted.items()}
|
k: decrypt_value(env_var_secret, v) for k, v in encrypted.items()
|
||||||
|
}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
self._tokens[bottle_id] = decrypted
|
||||||
|
if any(is_legacy_blob(value) for value in encrypted.values()):
|
||||||
|
migrated = {
|
||||||
|
key: encrypt_value(env_var_secret, value)
|
||||||
|
for key, value in decrypted.items()
|
||||||
|
}
|
||||||
|
self.registry.store_agent_secrets(bottle_id, migrated)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# --- consolidated gateway ----------------------------------------------
|
# --- consolidated gateway ----------------------------------------------
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ reattachment path reads ENV_VAR_SECRET from the running agent container via
|
|||||||
``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
|
``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
|
||||||
stored rows and re-populates ``_tokens``.
|
stored rows and re-populates ``_tokens``.
|
||||||
|
|
||||||
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode (stdlib-only,
|
Encryption scheme: encrypt-then-MAC using independent HMAC-SHA256-derived
|
||||||
no external deps). Each value is encrypted independently. The output blob is
|
encryption and authentication subkeys (stdlib-only, no external deps). Each
|
||||||
``nonce (16 bytes) || ciphertext`` encoded as URL-safe base64 (no padding).
|
value is encrypted independently. New output blobs are:
|
||||||
|
|
||||||
|
``version || nonce (16 bytes) || ciphertext || tag (32 bytes)``
|
||||||
|
|
||||||
|
encoded as URL-safe base64 (no padding). The version marker lets the reader
|
||||||
|
accept legacy ``nonce || ciphertext`` rows long enough to rewrite them in the
|
||||||
|
authenticated format after a successful reprovision.
|
||||||
|
|
||||||
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
|
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
|
||||||
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
|
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
|
||||||
@@ -30,6 +36,8 @@ import secrets
|
|||||||
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
|
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
|
||||||
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call
|
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call
|
||||||
_BLOCK = 32 # HMAC-SHA256 output width == one keystream block
|
_BLOCK = 32 # HMAC-SHA256 output width == one keystream block
|
||||||
|
_TAG_BYTES = 32
|
||||||
|
_VERSION = b"BBSE1"
|
||||||
|
|
||||||
# Env-var name the agent container receives at startup.
|
# Env-var name the agent container receives at startup.
|
||||||
ENV_VAR_SECRET_NAME = "ENV_VAR_SECRET"
|
ENV_VAR_SECRET_NAME = "ENV_VAR_SECRET"
|
||||||
@@ -41,7 +49,13 @@ def new_env_var_secret() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _b64dec(s: str) -> bytes:
|
def _b64dec(s: str) -> bytes:
|
||||||
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
return base64.b64decode(
|
||||||
|
s + "=" * (-len(s) % 4), altchars=b"-_", validate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(key: bytes, purpose: bytes) -> bytes:
|
||||||
|
return hmac.new(key, b"bot-bottle-secret-store:" + purpose, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
|
def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
|
||||||
@@ -53,42 +67,90 @@ def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
|
|||||||
def encrypt_value(secret_b64: str, plaintext: str) -> str:
|
def encrypt_value(secret_b64: str, plaintext: str) -> str:
|
||||||
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET).
|
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET).
|
||||||
|
|
||||||
Returns a URL-safe base64 blob ``nonce || ciphertext`` suitable for
|
Returns a URL-safe base64 authenticated blob suitable for
|
||||||
the ``bottled_agent_secrets.value`` column."""
|
the ``bottled_agent_secrets.value`` column."""
|
||||||
key = _b64dec(secret_b64)
|
key = _b64dec(secret_b64)
|
||||||
|
encryption_key = _subkey(key, b"encryption")
|
||||||
|
authentication_key = _subkey(key, b"authentication")
|
||||||
pt = plaintext.encode()
|
pt = plaintext.encode()
|
||||||
nonce = secrets.token_bytes(_NONCE_BYTES)
|
nonce = secrets.token_bytes(_NONCE_BYTES)
|
||||||
ct = bytearray()
|
ct = bytearray()
|
||||||
for i in range(0, len(pt), _BLOCK):
|
for i in range(0, len(pt), _BLOCK):
|
||||||
chunk = pt[i : i + _BLOCK]
|
chunk = pt[i : i + _BLOCK]
|
||||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
ks = _keystream(encryption_key, nonce, i // _BLOCK)[: len(chunk)]
|
||||||
ct.extend(p ^ k for p, k in zip(chunk, ks))
|
ct.extend(p ^ k for p, k in zip(chunk, ks))
|
||||||
return base64.urlsafe_b64encode(nonce + bytes(ct)).rstrip(b"=").decode()
|
authenticated = _VERSION + nonce + bytes(ct)
|
||||||
|
tag = hmac.new(authentication_key, authenticated, hashlib.sha256).digest()
|
||||||
|
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
def is_legacy_blob(blob_b64: str) -> bool:
|
||||||
"""Decrypt a blob produced by :func:`encrypt_value`.
|
"""Whether *blob_b64* uses the pre-authentication storage format."""
|
||||||
|
|
||||||
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
|
||||||
input or a key mismatch (wrong key produces garbage, not an error, unless
|
|
||||||
the plaintext is non-UTF-8 — treat all such failures as wrong key)."""
|
|
||||||
key = _b64dec(secret_b64)
|
|
||||||
try:
|
try:
|
||||||
blob = _b64dec(blob_b64)
|
return not _b64dec(blob_b64).startswith(_VERSION)
|
||||||
except Exception as exc:
|
except (ValueError, TypeError):
|
||||||
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_legacy(key: bytes, blob: bytes) -> str:
|
||||||
|
"""Read the original ``nonce || ciphertext`` format for migration only."""
|
||||||
if len(blob) < _NONCE_BYTES:
|
if len(blob) < _NONCE_BYTES:
|
||||||
raise ValueError("ciphertext blob too short")
|
raise ValueError("ciphertext blob too short")
|
||||||
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
|
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
|
||||||
pt = bytearray()
|
pt = bytearray()
|
||||||
|
# The legacy format used the byte offset as the PRF counter.
|
||||||
for i in range(0, len(ciphertext), _BLOCK):
|
for i in range(0, len(ciphertext), _BLOCK):
|
||||||
chunk = ciphertext[i : i + _BLOCK]
|
chunk = ciphertext[i : i + _BLOCK]
|
||||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||||
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
||||||
try:
|
try:
|
||||||
return bytes(pt).decode()
|
return bytes(pt).decode()
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError(f"decryption produced non-UTF-8 output: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
||||||
|
"""Decrypt a blob produced by :func:`encrypt_value`.
|
||||||
|
|
||||||
|
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
||||||
|
input, authentication failure, or a key mismatch. Legacy unauthenticated
|
||||||
|
rows remain readable so callers can migrate them immediately."""
|
||||||
|
key = _b64dec(secret_b64)
|
||||||
|
try:
|
||||||
|
blob = _b64dec(blob_b64)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
||||||
|
if not blob.startswith(_VERSION):
|
||||||
|
return _decrypt_legacy(key, blob)
|
||||||
|
minimum = len(_VERSION) + _NONCE_BYTES + _TAG_BYTES
|
||||||
|
if len(blob) < minimum:
|
||||||
|
raise ValueError("ciphertext blob too short")
|
||||||
|
authenticated, supplied_tag = blob[:-_TAG_BYTES], blob[-_TAG_BYTES:]
|
||||||
|
authentication_key = _subkey(key, b"authentication")
|
||||||
|
expected_tag = hmac.new(
|
||||||
|
authentication_key, authenticated, hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
if not hmac.compare_digest(supplied_tag, expected_tag):
|
||||||
|
raise ValueError("ciphertext authentication failed")
|
||||||
|
nonce_start = len(_VERSION)
|
||||||
|
nonce = blob[nonce_start : nonce_start + _NONCE_BYTES]
|
||||||
|
ciphertext = blob[nonce_start + _NONCE_BYTES : -_TAG_BYTES]
|
||||||
|
encryption_key = _subkey(key, b"encryption")
|
||||||
|
pt = bytearray()
|
||||||
|
for i in range(0, len(ciphertext), _BLOCK):
|
||||||
|
chunk = ciphertext[i : i + _BLOCK]
|
||||||
|
ks = _keystream(encryption_key, nonce, i // _BLOCK)[: len(chunk)]
|
||||||
|
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
||||||
|
try:
|
||||||
|
return bytes(pt).decode()
|
||||||
except UnicodeDecodeError as exc:
|
except UnicodeDecodeError as exc:
|
||||||
raise ValueError(f"decryption produced non-UTF-8 output (wrong key?): {exc}") from exc
|
raise ValueError(f"decryption produced non-UTF-8 output (wrong key?): {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ENV_VAR_SECRET_NAME", "new_env_var_secret", "encrypt_value", "decrypt_value"]
|
__all__ = [
|
||||||
|
"ENV_VAR_SECRET_NAME",
|
||||||
|
"new_env_var_secret",
|
||||||
|
"encrypt_value",
|
||||||
|
"decrypt_value",
|
||||||
|
"is_legacy_blob",
|
||||||
|
]
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from .paths import (
|
|||||||
|
|
||||||
class ProvisioningError(RuntimeError):
|
class ProvisioningError(RuntimeError):
|
||||||
"""A control-plane auth invariant would be violated (e.g. starting the
|
"""A control-plane auth invariant would be violated (e.g. starting the
|
||||||
orchestrator without its signing key — which would run OPEN)."""
|
orchestrator without its signing key)."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -67,9 +67,8 @@ class TrustDomain:
|
|||||||
|
|
||||||
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
|
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
|
||||||
"""The signing key as the owning process sees it — read from `key_env`
|
"""The signing key as the owning process sees it — read from `key_env`
|
||||||
(default `os.environ`). "" when unset; the caller decides whether that is
|
(default `os.environ`). ``""`` when unset; owning services reject that
|
||||||
fatal (`ControlPlaneProvisioning`) or the open-mode fallback
|
value rather than start without authentication."""
|
||||||
(`OrchestratorServer`)."""
|
|
||||||
env = os.environ if environ is None else environ
|
env = os.environ if environ is None else environ
|
||||||
return env.get(self.key_env, "").strip()
|
return env.get(self.key_env, "").strip()
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,7 @@ key.
|
|||||||
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
|
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
|
||||||
`roles=` arg (default unchanged) so a key can carry a different role set;
|
`roles=` arg (default unchanged) so a key can carry a different role set;
|
||||||
nothing else changes.
|
nothing else changes.
|
||||||
- Network topology, the plane split (#469), or the server's open-mode fallback
|
- Network topology or the plane split (#469).
|
||||||
for tests.
|
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# PRD 0082: Authoritative failure boundaries
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** codex
|
||||||
|
- **Created:** 2026-07-27
|
||||||
|
- **Issue:** #444
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make every security- or lifecycle-sensitive snapshot distinguish authoritative
|
||||||
|
empty state from unavailable state, and make every destructive or
|
||||||
|
resource-consuming boundary revalidate the assumptions it acts on. This
|
||||||
|
finishes the focused quality work begun under #444 without broad rewrites:
|
||||||
|
cleanup cannot act on stale identities, policy introspection cannot publish a
|
||||||
|
fabricated empty policy, gateway servers bound untrusted work, and daemon
|
||||||
|
shutdown does not emit uncaught background-thread failures. Shared
|
||||||
|
control-plane storage and gateway credential provisioning also enforce their
|
||||||
|
filesystem security contract before sensitive data is written.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Several paths are individually fail-closed but compose into unsafe or
|
||||||
|
misleading behavior:
|
||||||
|
|
||||||
|
1. `cleanup` prepares a plan, waits indefinitely for operator confirmation,
|
||||||
|
then kills stored PIDs and removes stored paths without checking that those
|
||||||
|
identities still describe the same orphan. A PID may be reused or a run
|
||||||
|
directory may become active during the prompt.
|
||||||
|
2. The supervisor reuses egress's deny-all fallback for
|
||||||
|
`list-egress-routes`. Deny-all is correct for enforcement, but presenting it
|
||||||
|
as a successful empty route table can cause a later replace-all proposal to
|
||||||
|
discard live routes.
|
||||||
|
3. The supervisor and Git HTTP services accept bounded declared body sizes but
|
||||||
|
use blocking reads and unbounded request threads. An untrusted bottle can
|
||||||
|
exhaust the shared gateway with slow or parallel requests.
|
||||||
|
4. macOS cleanup enumerates containers and networks independently and treats a
|
||||||
|
failed query as an empty class, so a partial snapshot can still become a
|
||||||
|
destructive plan.
|
||||||
|
5. Gateway log-pump threads race stream closure during shutdown and emit
|
||||||
|
uncaught exceptions even when shutdown otherwise succeeds.
|
||||||
|
6. Firecracker discovers VMs through whitespace-split `pgrep -a` output.
|
||||||
|
A configured cache path containing spaces can hide a live VM from the
|
||||||
|
snapshot and make its run directory appear orphaned.
|
||||||
|
7. Docker cleanup asks compose for its project snapshot in best-effort mode.
|
||||||
|
A transient query failure can therefore become an empty stopped-project
|
||||||
|
set and authorize deletion of associated state directories.
|
||||||
|
8. Firecracker artifact downloads and registry publication have no network
|
||||||
|
deadline, so an unresponsive registry can hold setup or release work
|
||||||
|
indefinitely.
|
||||||
|
9. Authenticated secret blobs select the unauthenticated legacy decoder when
|
||||||
|
their in-band version prefix is changed, allowing storage tampering to
|
||||||
|
bypass tag verification.
|
||||||
|
10. Cleanup executes the entire post-confirmation snapshot rather than the
|
||||||
|
intersection with what the operator saw, and mutation failures are not
|
||||||
|
reflected in the command result.
|
||||||
|
11. Git smart-HTTP can retain sixteen 100 MiB request bodies concurrently,
|
||||||
|
cleanup mutations have no subprocess deadline, and Firecracker signalling
|
||||||
|
failures bypass shared mutation accounting.
|
||||||
|
12. SQLite creates the shared control-plane database before its mode is
|
||||||
|
restricted, then suppresses permission-repair failures. Gateway transports
|
||||||
|
also differ in whether copied deploy-key modes are preserved.
|
||||||
|
|
||||||
|
These are one design problem: state used to authorize deletion, replacement,
|
||||||
|
or resource allocation must be authoritative at the point of use.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- Cleanup never signals a PID or recursively deletes a path solely because it
|
||||||
|
appeared in a pre-confirmation snapshot.
|
||||||
|
- Firecracker cleanup proves immediately before action that a PID is still the
|
||||||
|
same Firecracker process and that a run directory is still orphaned.
|
||||||
|
- Firecracker process discovery reads NUL-delimited argv from `/proc`; paths
|
||||||
|
are never reconstructed from whitespace-delimited process listings.
|
||||||
|
- All backend cleanup discovery primitives raise a typed enumeration error on
|
||||||
|
operational failure. No backend may independently continue from a partial
|
||||||
|
snapshot.
|
||||||
|
- Shared cleanup control flow lives in the backend layer; concrete backends
|
||||||
|
override resource-specific discovery and validation primitives rather than
|
||||||
|
each implementing a bespoke failure policy.
|
||||||
|
- `list-egress-routes` returns an MCP error when attribution or policy
|
||||||
|
resolution is unavailable. A genuine, authoritatively resolved empty policy
|
||||||
|
remains a successful empty list.
|
||||||
|
- Supervisor and Git HTTP request bodies have total read deadlines, and each
|
||||||
|
service bounds concurrent request work. Limits apply to authenticated
|
||||||
|
callers because bottles themselves are untrusted.
|
||||||
|
- Gateway child-output pumping treats expected stream closure during shutdown
|
||||||
|
as completion while preserving diagnostics for unexpected failures.
|
||||||
|
- Artifact pull, existence-check, and publication requests use explicit
|
||||||
|
network deadlines.
|
||||||
|
- Persisted secrets accept only the authenticated format. The schema migration
|
||||||
|
intentionally clears legacy rows; local agents are reprovisioned rather
|
||||||
|
than retaining a ciphertext-controlled downgrade path.
|
||||||
|
- Cleanup executes only resources present in both the displayed and current
|
||||||
|
authoritative plans, attempts every approved mutation, and returns failure
|
||||||
|
when any mutation does not complete.
|
||||||
|
- Git request bodies spool to disk behind a separate heavy-work semaphore;
|
||||||
|
cleanup commands have configurable deadlines; Firecracker signalling
|
||||||
|
failures aggregate while identity-verification uncertainty still aborts.
|
||||||
|
- The shared database directory and file are private before SQLite writes any
|
||||||
|
control-plane state; an inability to enforce those modes aborts startup.
|
||||||
|
- Gateway credential directories and files receive explicit private modes
|
||||||
|
inside the gateway, independent of Docker, Apple Container, or SSH copy
|
||||||
|
semantics.
|
||||||
|
- Unit tests cover PID/path reuse, partial backend enumeration, transient
|
||||||
|
policy resolution failure, slow bodies, concurrency saturation, and stream
|
||||||
|
closure races.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Further decomposition solely to reduce module line counts.
|
||||||
|
- Replacing gateway stdlib HTTP services with a web framework.
|
||||||
|
- Changing egress matching, DLP decisions, proposal semantics, or backend
|
||||||
|
launch behavior beyond the synchronization required for safe cleanup.
|
||||||
|
- Making cleanup silently skip uncertain resources. Uncertainty is an
|
||||||
|
operator-visible failure.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Shared backend control flow
|
||||||
|
|
||||||
|
Follow the backend architecture rule used by gateway attachment: shared
|
||||||
|
behavior lives above concrete backends; subclasses provide primitives, not
|
||||||
|
control flow.
|
||||||
|
|
||||||
|
Cleanup remains previewable, but confirmation authorizes a *new authoritative
|
||||||
|
evaluation*, not blind execution of the displayed object. The shared flow:
|
||||||
|
|
||||||
|
1. asks each available backend for a preview;
|
||||||
|
2. displays the union and asks for confirmation;
|
||||||
|
3. refreshes each non-empty backend plan;
|
||||||
|
4. validates destructive identities immediately before action;
|
||||||
|
5. aborts loudly if the refreshed plan or any identity cannot be proven safe.
|
||||||
|
|
||||||
|
Backend-specific primitives define how to identify a resource. Firecracker
|
||||||
|
uses process start identity plus canonical config/run paths; container
|
||||||
|
backends use authoritative CLI queries and stable resource names/labels.
|
||||||
|
|
||||||
|
Container engines expose destructive name-based commands without a portable
|
||||||
|
compare-and-delete operation. Cleanup therefore refreshes after confirmation
|
||||||
|
and requires every discovery query to succeed, minimizing but not claiming to
|
||||||
|
eliminate the final name-reuse race. A future engine-specific stable-ID
|
||||||
|
primitive may close that residual window without moving control flow back
|
||||||
|
into each backend.
|
||||||
|
|
||||||
|
### Enforcement state versus introspection state
|
||||||
|
|
||||||
|
Egress enforcement retains its deny-all fallback because uncertainty must not
|
||||||
|
grant network access. Supervisor introspection uses a strict resolver path:
|
||||||
|
unattributed callers and resolver failures become typed MCP errors, while a
|
||||||
|
successfully resolved policy containing zero routes returns `routes: []`.
|
||||||
|
|
||||||
|
### Gateway resource boundaries
|
||||||
|
|
||||||
|
Both stdlib servers set a per-connection body deadline before reading and use a
|
||||||
|
bounded request executor or semaphore. Saturated capacity fails quickly with a
|
||||||
|
service-unavailable response. Existing size caps remain independent:
|
||||||
|
supervisor proposals retain the 1 MiB cap and Git pack requests retain their
|
||||||
|
larger protocol-appropriate cap.
|
||||||
|
|
||||||
|
### Shutdown diagnostics
|
||||||
|
|
||||||
|
The gateway output pump catches only stream-closure exceptions expected after
|
||||||
|
the supervisor closes child pipes. Other I/O failures remain visible and are
|
||||||
|
reported through the supervisor's normal diagnostic channel.
|
||||||
|
|
||||||
|
### Shared filesystem security
|
||||||
|
|
||||||
|
The common SQLite store owns database creation for every backend. It creates
|
||||||
|
the parent directory and an empty database with private modes before opening
|
||||||
|
SQLite, repairs existing modes, verifies the resulting state, and propagates
|
||||||
|
every enforcement failure. Backend launchers do not duplicate this policy.
|
||||||
|
|
||||||
|
The backend-neutral gateway provisioner likewise applies directory and file
|
||||||
|
modes after transport copies complete. This avoids relying on copy behavior
|
||||||
|
that differs among Docker, Apple Container, and Firecracker's SSH transport.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
1. Existing fail-closed security and backend enumeration fixes.
|
||||||
|
2. FastAPI orchestrator transport and bounded control-plane bodies.
|
||||||
|
3. Egress request-policy and outbound-DLP pipeline extraction.
|
||||||
|
4. Supervisor MCP dispatch extraction.
|
||||||
|
5. Shared cleanup refresh/revalidation plus authoritative macOS discovery.
|
||||||
|
6. Strict supervisor introspection and bounded supervisor/Git HTTP work.
|
||||||
|
7. Gateway shutdown log-pump closure handling.
|
||||||
|
8. Lossless Firecracker process identities, authoritative Docker cleanup
|
||||||
|
queries, and bounded Firecracker artifact transfers.
|
||||||
|
9. Mandatory authenticated secret storage, shared cleanup-plan intersection
|
||||||
|
and mutation accounting, and contained Git backend process failures.
|
||||||
|
10. Disk-spooled and separately bounded Git bodies, cleanup command deadlines,
|
||||||
|
and classified Firecracker signalling failures.
|
||||||
|
11. Fail-closed shared database creation and backend-neutral gateway credential
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -32,17 +32,9 @@ not a principled scope exclusion: both are major hosted sandbox platforms and
|
|||||||
belong in this landscape even though they target platform builders rather than
|
belong in this landscape even though they target platform builders rather than
|
||||||
bot-bottle's local single-operator workflow.
|
bot-bottle's local single-operator workflow.
|
||||||
|
|
||||||
Updated 2026-07-27 after a scan of recent Show HN launches: **Black LLAB,
|
|
||||||
Eve, CloudRouter, Nucleus, yolo-cage, and Sandbox Agent SDK** added as a
|
|
||||||
dated entrant cohort. They sharpen the comparison on three axes the original
|
|
||||||
table underweighted: the browser/preview loop, parallel-agent operator UX, and
|
|
||||||
a provider-neutral automation/session API.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
The main table compares bot-bottle against fifteen canonical
|
The main table compares bot-bottle against fifteen isolation/sandbox tools.
|
||||||
isolation/sandbox tools; a later section evaluates six recent HN entrants
|
|
||||||
without widening an already unwieldy table.
|
|
||||||
Governance/pre-action authorization and credential-only layers are covered
|
Governance/pre-action authorization and credential-only layers are covered
|
||||||
separately because they don't provide VM or container isolation. None
|
separately because they don't provide VM or container isolation. None
|
||||||
duplicate bot-bottle's combination of local
|
duplicate bot-bottle's combination of local
|
||||||
@@ -550,199 +542,6 @@ them.
|
|||||||
framework runtime is not compromised.
|
framework runtime is not compromised.
|
||||||
- **Maturity**: Specification + reference implementation, 2026.
|
- **Maturity**: Specification + reference implementation, 2026.
|
||||||
|
|
||||||
## Recent HN entrants (added 2026-07-27)
|
|
||||||
|
|
||||||
These are grouped by launch date rather than promoted into the main table.
|
|
||||||
Several are young or sparsely documented, and putting them beside mature
|
|
||||||
runtime platforms with false precision would obscure the useful comparison.
|
|
||||||
The HN launch posts are the evidence snapshot; feature claims should be
|
|
||||||
rechecked against their repositories before relying on them for a security
|
|
||||||
decision.
|
|
||||||
|
|
||||||
### Black LLAB
|
|
||||||
|
|
||||||
- **Source**: https://github.com/isaacdear/black-llab ;
|
|
||||||
HN launch https://news.ycombinator.com/item?id=47402394
|
|
||||||
- **Isolation/locality**: Local Docker environment, with an isolated container
|
|
||||||
created for each agent task. Shared host kernel; no stronger boundary is
|
|
||||||
claimed.
|
|
||||||
- **Agent integration**: General local/cloud model workspace. Its headline is
|
|
||||||
dynamic routing of simple prompts to local models and complex prompts to
|
|
||||||
hosted models, with code execution and web scraping inside the task
|
|
||||||
container.
|
|
||||||
- **Network/credentials**: No default-deny egress, payload inspection, or
|
|
||||||
host-side credential injection documented in the launch.
|
|
||||||
- **Competitive read**: Superficial overlap ("a container per agent task"),
|
|
||||||
but not a direct security-policy competitor. Its useful challenge is the
|
|
||||||
integrated model-selection UX, which bot-bottle intentionally leaves to the
|
|
||||||
selected agent provider.
|
|
||||||
- **Maturity**: Early solo project; HN launch received 1 point.
|
|
||||||
|
|
||||||
### Eve
|
|
||||||
|
|
||||||
- **Source**: https://eve.new/ ;
|
|
||||||
HN launch https://news.ycombinator.com/item?id=47721255
|
|
||||||
- **Isolation/locality**: Managed, hosted Linux sandbox per user/session
|
|
||||||
(claimed 2 vCPU, 4 GB RAM, 10 GB disk), with filesystem, code execution,
|
|
||||||
headless Chromium, and service connectors.
|
|
||||||
- **Agent integration**: End-user OpenClaw-style agent product. An orchestrator
|
|
||||||
routes subtasks to specialist models and can run parallel subagents that
|
|
||||||
coordinate through a shared filesystem. Web UI and iMessage are primary
|
|
||||||
interaction surfaces.
|
|
||||||
- **Network/credentials**: Broad connectors are a product feature; the launch
|
|
||||||
does not document bot-bottle-style default-deny route policy, content DLP,
|
|
||||||
or credentials held outside the sandbox.
|
|
||||||
- **Competitive read**: Adjacent, not direct. Eve sells a managed colleague;
|
|
||||||
bot-bottle lets an operator run existing coding-agent CLIs under local
|
|
||||||
containment. Eve nevertheless demonstrates the appeal of background work,
|
|
||||||
live progress, browser capability, and mobile notification.
|
|
||||||
- **Maturity**: Commercial hosted product; HN launch received 71 points and
|
|
||||||
39 comments.
|
|
||||||
|
|
||||||
### CloudRouter
|
|
||||||
|
|
||||||
- **Source**: https://github.com/manaflow-ai/manaflow/tree/main/packages/cloudrouter ;
|
|
||||||
HN launch https://news.ycombinator.com/item?id=47006393
|
|
||||||
- **Isolation/locality**: Claude Code or Codex runs locally and provisions
|
|
||||||
remote cloud VMs/GPUs for execution. Project files are uploaded to the VM;
|
|
||||||
each machine exposes auth-protected VNC, VS Code, and Jupyter surfaces.
|
|
||||||
- **Agent integration**: A skill plus CLI lets the coding agent itself start,
|
|
||||||
command, inspect, and tear down machines. Browser automation is integrated,
|
|
||||||
including snapshots and screenshots. Parallel disposable compute is the
|
|
||||||
central workflow.
|
|
||||||
- **Network/credentials**: The launch emphasizes remote resource isolation and
|
|
||||||
authenticated UI endpoints, not default-deny guest egress, payload DLP, or
|
|
||||||
proxy-held application credentials.
|
|
||||||
- **Competitive read**: The closest recent workflow competitor. It directly
|
|
||||||
addresses parallel coding agents, environmental conflict, and closing the
|
|
||||||
browser/test loop, but trades local custody for elastic cloud compute.
|
|
||||||
Cloud VMs and GPUs could be a future bot-bottle backend; they do not replace
|
|
||||||
its manifest/policy layer.
|
|
||||||
- **Maturity**: Active open-source monorepo project; HN launch received
|
|
||||||
138 points and 36 comments.
|
|
||||||
|
|
||||||
### Nucleus
|
|
||||||
|
|
||||||
- **Source**: https://github.com/coproduct-opensource/nucleus ;
|
|
||||||
HN launch https://news.ycombinator.com/item?id=46855770
|
|
||||||
- **Isolation/locality**: Firecracker microVM with an enforcing MCP tool proxy.
|
|
||||||
- **Agent integration/config**: Compositional permission envelope for
|
|
||||||
read/write/run actions. The envelope is non-escalating and can tighten or
|
|
||||||
terminate, with scoped approval tokens for gated operations.
|
|
||||||
- **Network/credentials**: Default-deny egress, DNS allowlist, iptables drift
|
|
||||||
detection, time/budget caps, and hash-chained audit logging are claimed.
|
|
||||||
Remote append-only audit storage and attestation were roadmap items at
|
|
||||||
launch.
|
|
||||||
- **Competitive read**: Direct on security architecture, especially
|
|
||||||
non-escalating policy and tamper-evident audit. It is an early execution/tool
|
|
||||||
proxy rather than a provider-neutral, one-command coding-agent product. Its
|
|
||||||
tool-level action envelope is semantically finer than bot-bottle's network
|
|
||||||
boundary; bot-bottle is stronger on turnkey agent/provider integration,
|
|
||||||
credential custody, Git mediation, and long-running operator workflow.
|
|
||||||
- **Maturity**: Early OSS experiment; HN launch received 3 points.
|
|
||||||
|
|
||||||
### yolo-cage
|
|
||||||
|
|
||||||
- **Source**: https://github.com/borenstein/yolo-cage ;
|
|
||||||
HN launch https://news.ycombinator.com/item?id=46706796
|
|
||||||
- **Isolation/locality**: Local sandbox for running multiple coding agents in
|
|
||||||
YOLO mode. The launch discussion describes a VM boundary.
|
|
||||||
- **Agent integration**: Built around the native Claude Code experience and
|
|
||||||
motivated by running many agents in parallel without permission-prompt
|
|
||||||
fatigue.
|
|
||||||
- **Network/Git/credentials**: Strict egress filtering, configurable HTTP
|
|
||||||
middleware, and mediated `git`/`gh` dispatch are the main value. The launch
|
|
||||||
discussion explicitly identifies provider credential handling as unfinished
|
|
||||||
and difficult because Claude state spans multiple host paths.
|
|
||||||
- **Competitive read**: The closest new threat-model competitor. It shares
|
|
||||||
bot-bottle's premise that filesystem isolation alone is insufficient and
|
|
||||||
that Git plus authorized HTTP channels need mediation. bot-bottle currently
|
|
||||||
leads on cross-provider support, proxy-held Claude/Codex/forge credentials,
|
|
||||||
typed per-role manifests, content DLP, and supervision. yolo-cage's simpler
|
|
||||||
pitch and narrower Claude-first setup may be easier to explain.
|
|
||||||
- **Maturity**: Early local tool; HN launch received 60 points and 76 comments.
|
|
||||||
|
|
||||||
### Sandbox Agent SDK
|
|
||||||
|
|
||||||
- **Source**: https://github.com/rivet-dev/sandbox-agent ;
|
|
||||||
HN launch https://news.ycombinator.com/item?id=46795584
|
|
||||||
- **Isolation/locality**: Does not provide the isolation primitive. It runs
|
|
||||||
inside E2B, Daytona, Modal, Cloudflare Containers, Agent Computer, BoxLite,
|
|
||||||
Docker, or another sandbox provider. Embedded mode can also run locally
|
|
||||||
without a sandbox.
|
|
||||||
- **Agent integration**: Provider-neutral Rust server/SDK exposing a common
|
|
||||||
HTTP/SSE/OpenAPI interface across Claude Code, Codex, OpenCode, Cursor, Amp,
|
|
||||||
and Pi, plus a universal event/session schema for external storage and
|
|
||||||
replay. It also exposes filesystem, managed-process, terminal, MCP, skills,
|
|
||||||
custom-tool, and computer-use APIs. TypeScript is the primary SDK surface.
|
|
||||||
- **Network/credentials**: Delegated to the chosen sandbox provider.
|
|
||||||
- **Credential posture**: Its documented convenience command extracts real
|
|
||||||
OpenAI/Anthropic credentials from local agent configuration and passes them
|
|
||||||
as environment variables into the sandbox. That is materially weaker than
|
|
||||||
bot-bottle's host-side credential custody, but it is an integration choice,
|
|
||||||
not a structural limitation: a sandbox provider could put a credential
|
|
||||||
proxy underneath the same SDK.
|
|
||||||
- **Competitive read**: A serious architectural threat despite not supplying
|
|
||||||
isolation. Sandbox Agent is trying to standardize the boundary *above* the
|
|
||||||
sandbox: one client protocol, session model, and UI/control surface across
|
|
||||||
every coding agent and runtime. If that boundary becomes the ecosystem
|
|
||||||
standard, users and application builders may choose a sandbox provider plus
|
|
||||||
Sandbox Agent rather than a vertically integrated launcher. bot-bottle's
|
|
||||||
manifests would then be valuable chiefly as a local policy/backend
|
|
||||||
implementation unless they expose an equally usable control contract.
|
|
||||||
- **Maturity**: Apache 2.0, ~1.5k stars and 426 commits at the 2026-07-27
|
|
||||||
check; HN launch received 41 points.
|
|
||||||
|
|
||||||
#### Why the Sandbox Agent architecture is strategically different
|
|
||||||
|
|
||||||
The manifest and the universal control protocol solve different layers:
|
|
||||||
|
|
||||||
- A bot-bottle manifest is a **trusted launch-time policy composition**. It
|
|
||||||
selects the agent role, isolation backend, image, skills, egress routes,
|
|
||||||
credentials, Git mediation, and supervision policy. Crucially, identity and
|
|
||||||
secret references live on the host side of the trust boundary.
|
|
||||||
- Sandbox Agent is a **runtime control and observation protocol**. A remote
|
|
||||||
client creates sessions, sends messages, handles permissions, configures
|
|
||||||
skills/MCP, manipulates files/processes/desktops, and streams normalized
|
|
||||||
events. It deliberately delegates sandbox lifecycle, Git management,
|
|
||||||
storage, network policy, and credential security to other products.
|
|
||||||
|
|
||||||
That makes it complementary in a component diagram but competitive in product
|
|
||||||
architecture. The layer that becomes the stable integration point tends to own
|
|
||||||
the ecosystem. Three plausible threat paths matter:
|
|
||||||
|
|
||||||
1. **Standard control plane, interchangeable runtimes.** Applications integrate
|
|
||||||
once with Sandbox Agent and treat E2B, Daytona, BoxLite, Docker, or a future
|
|
||||||
local microVM as replaceable compute. A provider that bundles adequate
|
|
||||||
egress and credential custody makes bot-bottle's end-to-end launcher less
|
|
||||||
necessary.
|
|
||||||
2. **Policy grows upward.** Sandbox Agent already configures permissions,
|
|
||||||
skills, MCP, custom tools, filesystem/process access, and computer use. If
|
|
||||||
it adds a declarative, host-verifiable policy document, the overlap with
|
|
||||||
agent/bottle manifests becomes substantial even if enforcement remains
|
|
||||||
delegated.
|
|
||||||
3. **UI and session ownership.** Its universal transcript schema, Inspector,
|
|
||||||
React components, event replay, and remote terminal/computer APIs can become
|
|
||||||
the natural basis for desktop, web, and mobile agent managers. bot-bottle's
|
|
||||||
security layer could remain stronger while losing the operator surface and
|
|
||||||
distribution channel.
|
|
||||||
|
|
||||||
The counter-position is not to claim that manifests and an API are mutually
|
|
||||||
exclusive. The defensible split is:
|
|
||||||
|
|
||||||
- bot-bottle owns the trusted policy and enforcement plane outside the agent;
|
|
||||||
- a provider-neutral protocol owns agent process control and normalized
|
|
||||||
events; and
|
|
||||||
- the operator UI consumes both.
|
|
||||||
|
|
||||||
This suggests an explicit compatibility decision rather than parallel,
|
|
||||||
accidental protocol design: evaluate running Sandbox Agent inside a bottle and
|
|
||||||
exposing it only through the authenticated bot-bottle control plane. If its
|
|
||||||
schema is suitable, adopting it could turn a threat into an integration while
|
|
||||||
keeping manifests as the higher-trust policy source. If it is unsuitable,
|
|
||||||
bot-bottle should still publish a stable provider-neutral session/event API so
|
|
||||||
frontends do not depend on Claude/Codex/Pi-specific process behavior.
|
|
||||||
|
|
||||||
## Comparison table
|
## Comparison table
|
||||||
|
|
||||||
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
|
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
|
||||||
@@ -817,70 +616,6 @@ would be a *backend* bot-bottle could call, not a competitor to its
|
|||||||
manifest layer. endo-familiar is in a different paradigm entirely:
|
manifest layer. endo-familiar is in a different paradigm entirely:
|
||||||
capability passing rather than kernel boundaries.
|
capability passing rather than kernel boundaries.
|
||||||
|
|
||||||
**Recent entrants change two parts of this read.** yolo-cage is closer to the
|
|
||||||
actual threat model than agent-safehouse or litterbox: it combines a VM-style
|
|
||||||
boundary with mediated Git and filtered HTTP specifically for parallel coding
|
|
||||||
agents. Sandbox Agent SDK is the more important strategic entrant even though
|
|
||||||
it supplies no isolation. It can become the standard agent-control layer above
|
|
||||||
all of these runtimes, including a future bot-bottle backend. CloudRouter is
|
|
||||||
the clearest workflow challenge because its browser/desktop/GPU loop makes
|
|
||||||
parallel agents visibly more capable, not merely safer.
|
|
||||||
|
|
||||||
## Gap evaluation after the 2026-07-27 entrant scan
|
|
||||||
|
|
||||||
### Material gaps
|
|
||||||
|
|
||||||
1. **A stable provider-neutral control and event protocol.** This is the
|
|
||||||
largest newly visible gap. bot-bottle normalizes launch/provisioning across
|
|
||||||
providers, but an external UI or orchestrator still lacks one documented
|
|
||||||
contract for creating a Claude/Codex/Pi session, sending input, handling
|
|
||||||
permission/supervision events, streaming normalized output, reconnecting,
|
|
||||||
and replaying history. Sandbox Agent SDK addresses exactly this layer and
|
|
||||||
is already portable across many sandbox providers.
|
|
||||||
2. **Browser/preview closure.** CloudRouter and Eve make a browser or desktop
|
|
||||||
part of the standard agent environment and expose screenshots/live viewing
|
|
||||||
to the operator. bot-bottle can run dev servers and supports nested
|
|
||||||
containers, but it does not present a first-class browser/computer-use
|
|
||||||
primitive or an auth-protected preview surface. For coding agents expected
|
|
||||||
to verify UI work, this is a real product gap.
|
|
||||||
3. **Unified parallel-session operator UX.** Named persistent bottles and
|
|
||||||
supervision provide the substrate, but the recent products make task
|
|
||||||
switching, live progress, notifications, terminal attach, diffs, and
|
|
||||||
session history the product. Security depth will not compensate for a
|
|
||||||
visibly rougher daily loop.
|
|
||||||
4. **Normalized transcript persistence and replay.** bot-bottle preserves
|
|
||||||
provider-specific state for resume; it does not expose a provider-neutral
|
|
||||||
event record suitable for audit, replay, analytics, or a web/mobile client.
|
|
||||||
This is both a UX gap and an audit gap.
|
|
||||||
|
|
||||||
### Important, but not necessarily bot-bottle features
|
|
||||||
|
|
||||||
- **Cloud VM/GPU provisioning.** Valuable for elastic workloads and could be a
|
|
||||||
backend, but it conflicts with the local-custody default and should not
|
|
||||||
displace core policy work.
|
|
||||||
- **Automatic model routing.** Black LLAB and Eve sell task-to-model routing.
|
|
||||||
bot-bottle's provider-template boundary can host that choice without making
|
|
||||||
it part of the trusted sandbox policy.
|
|
||||||
- **A thousand SaaS connectors.** This broadens capability and blast radius.
|
|
||||||
The bot-bottle-native answer should remain explicit, scoped forge/egress
|
|
||||||
associations rather than connector count as a goal.
|
|
||||||
- **SDK-driven sandbox lifecycle as the primary configuration model.** Useful
|
|
||||||
for platform builders, but not a replacement for reviewable, host-owned
|
|
||||||
manifests. A control API and a declarative policy source are compatible;
|
|
||||||
neither should silently become the other.
|
|
||||||
|
|
||||||
### Areas where bot-bottle remains ahead
|
|
||||||
|
|
||||||
- real provider and forge credentials remain outside the agent process rather
|
|
||||||
than being extracted into its environment;
|
|
||||||
- authorized HTTP payloads are scanned, not merely destination-filtered;
|
|
||||||
- Git writes traverse a distinct gate with secret scanning and host-held
|
|
||||||
upstream credentials;
|
|
||||||
- role policy is host-owned, composable, and separate from untrusted repo
|
|
||||||
content; and
|
|
||||||
- local Firecracker/Apple Container execution preserves operator custody
|
|
||||||
without requiring a hosted sandbox platform.
|
|
||||||
|
|
||||||
## Borrowable ideas
|
## Borrowable ideas
|
||||||
|
|
||||||
### Already shipped or otherwise addressed
|
### Already shipped or otherwise addressed
|
||||||
@@ -907,19 +642,6 @@ parallel agents visibly more capable, not merely safer.
|
|||||||
|
|
||||||
### Still worth considering
|
### Still worth considering
|
||||||
|
|
||||||
- **Sandbox Agent compatibility or an equivalent stable protocol (highest
|
|
||||||
priority):** spike running its server inside a bottle behind bot-bottle's
|
|
||||||
authenticated control plane. Compare its session/event schema, permission
|
|
||||||
model, restore semantics, and provider coverage with current provider
|
|
||||||
adapters. Adopt compatibility if it preserves the host-owned trust boundary;
|
|
||||||
otherwise specify bot-bottle's own stable API before building another UI.
|
|
||||||
- **First-class browser/preview loop** (from CloudRouter and Eve): give a
|
|
||||||
bottle an optional browser/computer-use capability plus an operator-visible,
|
|
||||||
authenticated preview/screenshot surface. Treat its network access as part
|
|
||||||
of the bottle policy, not an implicit bypass.
|
|
||||||
- **Provider-neutral transcript/event persistence** (from Sandbox Agent SDK):
|
|
||||||
retain enough normalized structure for replay and audit while preserving the
|
|
||||||
provider-native state needed for exact resume.
|
|
||||||
- **Live network activity in the supervisor TUI** (from Docker sbx): show
|
- **Live network activity in the supervisor TUI** (from Docker sbx): show
|
||||||
allowed and blocked connections and let the operator propose policy changes
|
allowed and blocked connections and let the operator propose policy changes
|
||||||
from the existing supervision surface.
|
from the existing supervision surface.
|
||||||
@@ -930,11 +652,10 @@ parallel agents visibly more capable, not merely safer.
|
|||||||
closer review. This needs a carefully specified trust model before it can be
|
closer review. This needs a carefully specified trust model before it can be
|
||||||
more than a heuristic.
|
more than a heuristic.
|
||||||
|
|
||||||
Not worth borrowing: SDK-first *policy configuration* as used by boxlite /
|
Not worth borrowing: the SDK-first programmatic API style of boxlite /
|
||||||
microsandbox (cuts against the reviewable declarative-manifest stance), and
|
microsandbox (cuts against the declarative-manifest stance), and the
|
||||||
the hosted-SaaS custody model of tilde.run (cuts against the "infrastructure I
|
hosted-SaaS dashboard model of tilde.run (cuts against the
|
||||||
control" goal). A provider-neutral runtime-control API is a separate concern
|
"infrastructure I control" goal).
|
||||||
and is worth borrowing.
|
|
||||||
|
|
||||||
## Publishing and positioning verdict
|
## Publishing and positioning verdict
|
||||||
|
|
||||||
@@ -958,15 +679,9 @@ bot-bottle remains unusual in combining:
|
|||||||
The practical wedge is “as easy as native yolo, with declarative role policy
|
The practical wedge is “as easy as native yolo, with declarative role policy
|
||||||
and self-hosted custody,” including scoped access to private LAN/Tailnet
|
and self-hosted custody,” including scoped access to private LAN/Tailnet
|
||||||
services that cloud-first runtimes cannot provide without additional network
|
services that cloud-first runtimes cannot provide without additional network
|
||||||
plumbing. The main competitive risks are now:
|
plumbing. The main competitive risks are a local wrapper such as claudebox or
|
||||||
|
Docker sbx growing a role-manifest layer, and GUI products such as SuperHQ
|
||||||
- a local wrapper such as yolo-cage, claudebox, or Docker sbx growing a
|
adding equivalent policy and audit depth.
|
||||||
role-manifest and credential-custody layer;
|
|
||||||
- Sandbox Agent SDK becoming the standard control/session boundary and making
|
|
||||||
the runtime beneath it interchangeable; and
|
|
||||||
- GUI products such as SuperHQ or CloudRouter adding equivalent policy and
|
|
||||||
audit depth before bot-bottle closes the browser/preview and
|
|
||||||
parallel-session UX gaps.
|
|
||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
|
|||||||
@@ -1,536 +0,0 @@
|
|||||||
# Sandbox Agent SDK and bot-bottle: protocol versus product
|
|
||||||
|
|
||||||
This note asks whether [Sandbox Agent SDK](https://github.com/rivet-dev/sandbox-agent)
|
|
||||||
and bot-bottle compete for the same architectural layer, whether bot-bottle
|
|
||||||
can productize the turnkey ecosystem/DX layer above it, and how far the
|
|
||||||
Docker/OCI analogy actually holds.
|
|
||||||
|
|
||||||
Research conducted 2026-07-27. Sandbox Agent SDK was at the `0.4.x` line,
|
|
||||||
Apache 2.0, and documented support for Claude Code, Codex, OpenCode, Cursor,
|
|
||||||
Amp, and Pi at the time of review.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**The projects are complementary at the component boundary and competitive at
|
|
||||||
the product boundary.** Sandbox Agent SDK normalizes how software controls a
|
|
||||||
coding-agent process inside an arbitrary sandbox. bot-bottle decides what
|
|
||||||
sandbox to create, what trusted role and policy it receives, how credentials
|
|
||||||
and Git access cross the boundary, how traffic is constrained, and how an
|
|
||||||
operator launches and supervises the result.
|
|
||||||
|
|
||||||
The Docker analogy is useful with one correction:
|
|
||||||
|
|
||||||
- Sandbox Agent SDK is not equivalent to Linux container APIs or OCI itself.
|
|
||||||
It is closer to a **containerd shim plus a portable exec/session API for
|
|
||||||
coding agents**. It adapts incompatible agent processes to one HTTP/SSE
|
|
||||||
contract.
|
|
||||||
- A future independent agent-session specification would be the closer OCI
|
|
||||||
analogue.
|
|
||||||
- bot-bottle can credibly occupy the **Docker Engine / Compose / Desktop**
|
|
||||||
layer: packaging, policy composition, lifecycle, networking, credentials,
|
|
||||||
storage, operator UX, and a one-command experience above interchangeable
|
|
||||||
agent adapters and isolation runtimes.
|
|
||||||
|
|
||||||
That is a viable position, but “turnkey wrapper” undersells it. A thin wrapper
|
|
||||||
is replaceable. The valuable product is a **turnkey, policy-first coding-agent
|
|
||||||
runtime** whose manifest compiles trusted operator intent into multiple
|
|
||||||
enforcement planes. Sandbox Agent SDK may be one internal process-control
|
|
||||||
component of that product.
|
|
||||||
|
|
||||||
The recommended direction is:
|
|
||||||
|
|
||||||
1. Keep the bot-bottle manifest as the host-owned source of trusted policy.
|
|
||||||
2. Spike Sandbox Agent SDK as the in-bottle provider/session adapter.
|
|
||||||
3. Expose a stable, provider-neutral bot-bottle control API, compatible with
|
|
||||||
Sandbox Agent where practical.
|
|
||||||
4. Keep security decisions and authoritative audit outside the sandbox.
|
|
||||||
5. Build the ecosystem around policy packs, agent images, skills, backends,
|
|
||||||
operator UI, and trusted integrations—not around a proprietary transcript
|
|
||||||
protocol.
|
|
||||||
|
|
||||||
## What each project is today
|
|
||||||
|
|
||||||
### Sandbox Agent SDK
|
|
||||||
|
|
||||||
Sandbox Agent is a Rust server that runs alongside the coding agent. A client
|
|
||||||
connects over HTTP, streams events over SSE, and uses one API across agent
|
|
||||||
implementations. Its documented surface includes:
|
|
||||||
|
|
||||||
- creating and restoring agent sessions;
|
|
||||||
- sending messages and streaming normalized events;
|
|
||||||
- handling permissions;
|
|
||||||
- configuring MCP servers, skills, and custom tools;
|
|
||||||
- filesystem and managed-process APIs;
|
|
||||||
- interactive terminal access;
|
|
||||||
- computer-use/desktop operations;
|
|
||||||
- a universal session/transcript schema;
|
|
||||||
- an Inspector UI, React components, CLI, TypeScript SDK, and OpenAPI spec.
|
|
||||||
|
|
||||||
It can run in embedded mode or inside E2B, Daytona, Modal, Cloudflare
|
|
||||||
Containers, Agent Computer, BoxLite, Docker, and other environments. It
|
|
||||||
explicitly leaves these concerns to the caller or sandbox provider:
|
|
||||||
|
|
||||||
- sandbox creation and lifecycle;
|
|
||||||
- Git repository management;
|
|
||||||
- durable session storage;
|
|
||||||
- network policy;
|
|
||||||
- isolation strength; and
|
|
||||||
- secure credential delivery.
|
|
||||||
|
|
||||||
Its documented credential convenience path extracts real provider credentials
|
|
||||||
from local agent configuration and passes them into the sandbox environment.
|
|
||||||
That is convenient but is not an acceptable security boundary for bot-bottle.
|
|
||||||
|
|
||||||
Sources:
|
|
||||||
|
|
||||||
- [Sandbox Agent repository and architecture](https://github.com/rivet-dev/sandbox-agent)
|
|
||||||
- [Sandbox Agent documentation](https://sandboxagent.dev/docs)
|
|
||||||
- [HTTP API](https://sandboxagent.dev/docs/api-reference)
|
|
||||||
- [Universal session/transcript schema](https://sandboxagent.dev/docs/session-transcript-schema)
|
|
||||||
|
|
||||||
### bot-bottle
|
|
||||||
|
|
||||||
bot-bottle is a host-side launch, policy, and enforcement system for existing
|
|
||||||
coding-agent CLIs. Its current architecture includes:
|
|
||||||
|
|
||||||
- agent and bottle manifests with composition via `extends:`;
|
|
||||||
- a host-only trust boundary for roles, identity, and secret references;
|
|
||||||
- provider templates and plugins for Claude Code, Codex, Pi, and custom
|
|
||||||
providers;
|
|
||||||
- Firecracker on KVM Linux and Apple Container on macOS, with Docker fallback;
|
|
||||||
- image construction and provider-specific provisioning;
|
|
||||||
- default-deny inspected egress with path/method/header policy;
|
|
||||||
- payload DLP on authorized channels;
|
|
||||||
- real credentials held outside the agent and injected by the gateway;
|
|
||||||
- Git mediation, upstream credential custody, and gitleaks scanning;
|
|
||||||
- a per-host authenticated orchestrator and shared gateway;
|
|
||||||
- named bottle lifecycle, resume, supervision, and audit state; and
|
|
||||||
- a CLI/TUI intended to make full-permission agents operationally tolerable.
|
|
||||||
|
|
||||||
The provider layer currently normalizes launch-time concerns—command, image,
|
|
||||||
prompt delivery, files, skills, environment, verification, and provider-owned
|
|
||||||
egress routes. It does **not** yet expose a stable provider-neutral runtime
|
|
||||||
contract for sessions, messages, transcripts, terminals, or normalized events.
|
|
||||||
That is the gap Sandbox Agent directly illuminates.
|
|
||||||
|
|
||||||
Sources in this repository:
|
|
||||||
|
|
||||||
- [`README.md`](../../README.md)
|
|
||||||
- [`0070-per-host-orchestrator.md`](../prds/0070-per-host-orchestrator.md)
|
|
||||||
- [`0026-agent-provider-templates.md`](../prds/0026-agent-provider-templates.md)
|
|
||||||
- [`0053-user-provider-plugins.md`](../prds/0053-user-provider-plugins.md)
|
|
||||||
- [`agent_provider.py`](../../bot_bottle/agent_provider.py)
|
|
||||||
|
|
||||||
## The layer model
|
|
||||||
|
|
||||||
The cleanest architecture has four layers:
|
|
||||||
|
|
||||||
| Layer | Responsibility | Likely owner |
|
|
||||||
|---|---|---|
|
|
||||||
| Operator product | Install, select a role, launch, observe, intervene, resume, review changes | bot-bottle |
|
|
||||||
| Trusted policy and lifecycle | Compose manifest, choose backend/image, hold credentials, enforce egress/Git, persist authoritative audit | bot-bottle |
|
|
||||||
| Agent control protocol | Start provider process, create session, send input, stream normalized events, terminal/computer operations | Sandbox Agent or a compatible protocol |
|
|
||||||
| Isolation primitive | VM/container/process boundary, filesystem, CPU/memory, networking substrate | Firecracker, Apple Container, Docker, E2B, Daytona, BoxLite, etc. |
|
|
||||||
|
|
||||||
The important boundary is between trusted policy/lifecycle and agent control.
|
|
||||||
The agent-control daemon runs in the environment being treated as untrusted.
|
|
||||||
It can report what the agent says happened, but it cannot authoritatively prove
|
|
||||||
that policy was enforced. Egress decisions, credential custody, Git scanning,
|
|
||||||
bottle identity, and security audit must remain outside it.
|
|
||||||
|
|
||||||
### Proposed composition
|
|
||||||
|
|
||||||
```text
|
|
||||||
operator UI / CLI / API
|
|
||||||
|
|
|
||||||
v
|
|
||||||
bot-bottle orchestrator (trusted)
|
|
||||||
- resolves manifest
|
|
||||||
- owns bottle identity and lifecycle
|
|
||||||
- stores authoritative audit
|
|
||||||
- authenticates clients
|
|
||||||
|
|
|
||||||
+--------------------------+
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
isolation backend shared gateway (trusted)
|
|
||||||
Firecracker / Apple / Docker - egress policy + DLP
|
|
||||||
| - credential injection
|
|
||||||
| - Git mediation
|
|
||||||
v
|
|
||||||
bottle / guest (untrusted)
|
|
||||||
- Sandbox Agent server
|
|
||||||
- Claude Code / Codex / Pi subprocess
|
|
||||||
- workspace, skills, MCP configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
The bot-bottle manifest would compile into both sides:
|
|
||||||
|
|
||||||
- **outside the bottle:** backend, network, egress, credentials, Git,
|
|
||||||
supervision, identity, and authoritative lifecycle;
|
|
||||||
- **inside the bottle:** selected provider, prompt, skills, MCP configuration,
|
|
||||||
startup arguments, and non-secret session metadata.
|
|
||||||
|
|
||||||
Sandbox Agent should never receive real secrets merely because its API offers
|
|
||||||
a credential extraction helper. Provider and forge requests should continue
|
|
||||||
to use bot-bottle's placeholder/proxy pattern.
|
|
||||||
|
|
||||||
## How accurate is the Docker/OCI analogy?
|
|
||||||
|
|
||||||
### The useful part
|
|
||||||
|
|
||||||
The container ecosystem separates low-level execution from a product that
|
|
||||||
ordinary developers operate. OCI defines interoperable image, runtime, and
|
|
||||||
distribution specifications. Docker Engine adds a daemon, API, CLI, object
|
|
||||||
model, images, networks, volumes, and lifecycle; Docker Desktop and related
|
|
||||||
products add installation, updates, UI, integrations, policy, and team
|
|
||||||
workflows.
|
|
||||||
|
|
||||||
The same separation can exist for coding agents:
|
|
||||||
|
|
||||||
| Container ecosystem | Agent-sandbox ecosystem |
|
|
||||||
|---|---|
|
|
||||||
| OCI/runtime contract | A future open agent session/event contract |
|
|
||||||
| `runc` / runtime adapter | Claude/Codex/Pi adapter |
|
|
||||||
| containerd shim and task/exec API | Sandbox Agent server and HTTP/SSE session API |
|
|
||||||
| containerd / CRI-style lifecycle | Sandbox-provider lifecycle APIs |
|
|
||||||
| Docker Engine / Compose | bot-bottle orchestrator + manifests + backends + gateway |
|
|
||||||
| Docker Desktop / Hub ecosystem | bot-bottle desktop/mobile UX, policy packs, agent images, skills, trusted integrations |
|
|
||||||
|
|
||||||
Sandbox Agent makes coding-agent processes portable in roughly the way a shim
|
|
||||||
makes runtimes consumable through a common lifecycle interface. bot-bottle can
|
|
||||||
make the entire safe-agent system usable without asking the operator to
|
|
||||||
assemble that plumbing.
|
|
||||||
|
|
||||||
Official container references:
|
|
||||||
|
|
||||||
- [Open Container Initiative](https://opencontainers.org/)
|
|
||||||
- [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec)
|
|
||||||
- [Docker Engine architecture](https://docs.docker.com/engine/)
|
|
||||||
- [Docker alternative runtimes and containerd shims](https://docs.docker.com/engine/daemon/alternative-runtimes/)
|
|
||||||
|
|
||||||
### Where the analogy breaks
|
|
||||||
|
|
||||||
1. **Sandbox Agent is an implementation, not an independent standard.**
|
|
||||||
Its OpenAPI document is public, but the project currently owns the server,
|
|
||||||
adapters, schema, and evolution. OCI is an independently governed set of
|
|
||||||
specifications with multiple implementations.
|
|
||||||
2. **It sits above, not below, the isolation boundary.** Linux namespaces,
|
|
||||||
cgroups, VMs, and OCI runtimes create the boundary. Sandbox Agent controls a
|
|
||||||
process after some other system has created that boundary.
|
|
||||||
3. **It reaches into product territory.** Inspector, React components,
|
|
||||||
computer-use APIs, skills/MCP configuration, transcripts, and restoration
|
|
||||||
are not merely low-level primitives. Sandbox Agent can continue growing
|
|
||||||
upward into the same UI and orchestration space bot-bottle might occupy.
|
|
||||||
4. **Coding agents are semantically uneven.** Normalizing a container
|
|
||||||
lifecycle is easier than claiming full behavioral parity across Claude
|
|
||||||
Code, Codex, Cursor, Amp, OpenCode, and Pi. A universal schema can become a
|
|
||||||
lowest common denominator or accumulate provider-specific escape hatches.
|
|
||||||
5. **The security contract is not standardized.** An agent-session API says
|
|
||||||
little about whether credentials are visible, egress is controlled, Git is
|
|
||||||
mediated, or audit is trustworthy. Those are core bot-bottle concerns.
|
|
||||||
|
|
||||||
The positioning should therefore say “Docker-like product layer above an open
|
|
||||||
agent-control protocol,” not “Sandbox Agent is OCI” or “bot-bottle implements
|
|
||||||
OCI for agents.”
|
|
||||||
|
|
||||||
## Can bot-bottle be the turnkey product layer?
|
|
||||||
|
|
||||||
Yes, if it owns substantially more than launch syntax.
|
|
||||||
|
|
||||||
The turnkey promise is:
|
|
||||||
|
|
||||||
> Choose a trusted role, point it at a project, and run any supported coding
|
|
||||||
> agent with full permissions. bot-bottle builds the environment, isolates it,
|
|
||||||
> supplies only the capabilities it needs, keeps credentials outside, mediates
|
|
||||||
> external writes, and gives the operator one place to watch and intervene.
|
|
||||||
|
|
||||||
That product has several defensible jobs:
|
|
||||||
|
|
||||||
### 1. Packaging and reproducibility
|
|
||||||
|
|
||||||
- provider and toolchain images;
|
|
||||||
- pinned, verified build inputs;
|
|
||||||
- skills and MCP configuration;
|
|
||||||
- role/bottle composition;
|
|
||||||
- cached startup and portable environment definitions; and
|
|
||||||
- compatibility testing across agents and backends.
|
|
||||||
|
|
||||||
### 2. Trusted policy compilation
|
|
||||||
|
|
||||||
The manifest is valuable because one reviewable document compiles into:
|
|
||||||
|
|
||||||
- an isolation plan;
|
|
||||||
- gateway routes and DLP policy;
|
|
||||||
- credential slots;
|
|
||||||
- Git-gate repositories and identities;
|
|
||||||
- provider configuration;
|
|
||||||
- supervision behavior; and
|
|
||||||
- operator-facing preflight.
|
|
||||||
|
|
||||||
Sandbox Agent's runtime configuration does not replace this. The policy must
|
|
||||||
be resolved before an untrusted guest or agent-control daemon exists.
|
|
||||||
|
|
||||||
### 3. Security enforcement
|
|
||||||
|
|
||||||
- dedicated-kernel isolation where available;
|
|
||||||
- no direct guest route to the internet;
|
|
||||||
- credentials injected outside the agent;
|
|
||||||
- content inspection on allowed destinations;
|
|
||||||
- Git secrets scanning and upstream-key custody;
|
|
||||||
- fail-closed policy resolution; and
|
|
||||||
- authoritative host-side audit.
|
|
||||||
|
|
||||||
This is the strongest current differentiation from a generic
|
|
||||||
“Sandbox Agent + Docker/E2B” assembly.
|
|
||||||
|
|
||||||
### 4. Lifecycle and operations
|
|
||||||
|
|
||||||
- install and host preflight;
|
|
||||||
- image build/update;
|
|
||||||
- start, stop, resume, cleanup, and migration;
|
|
||||||
- concurrent named agents;
|
|
||||||
- state recovery after crashes;
|
|
||||||
- live supervision and policy remediation; and
|
|
||||||
- backend selection without changing the role definition.
|
|
||||||
|
|
||||||
### 5. Ecosystem and DX
|
|
||||||
|
|
||||||
A product layer can support:
|
|
||||||
|
|
||||||
- curated provider images;
|
|
||||||
- signed policy/bottle packs;
|
|
||||||
- reusable role templates;
|
|
||||||
- skills and MCP bundles;
|
|
||||||
- backend plugins;
|
|
||||||
- an authenticated desktop/web/mobile operator client;
|
|
||||||
- browser/preview integration;
|
|
||||||
- normalized transcripts and change review; and
|
|
||||||
- team policy distribution and compliance exports.
|
|
||||||
|
|
||||||
The analogy to Docker is strongest here: users adopt the coherent workflow and
|
|
||||||
ecosystem, not because the low-level process API is proprietary.
|
|
||||||
|
|
||||||
## Business and product positioning
|
|
||||||
|
|
||||||
“Turnkey wrapper” is understandable internally but weak externally. It implies
|
|
||||||
that the hard work lives underneath and that another wrapper can replace it.
|
|
||||||
Prefer one of:
|
|
||||||
|
|
||||||
- **The policy-first runtime for coding agents**
|
|
||||||
- **Run any coding agent with full permissions, without giving it your host or
|
|
||||||
credentials**
|
|
||||||
- **A turnkey local control plane for isolated coding agents**
|
|
||||||
- **Docker-like packaging and operations for coding agents, with the security
|
|
||||||
boundary outside the agent**
|
|
||||||
|
|
||||||
The open/product split could resemble the container ecosystem:
|
|
||||||
|
|
||||||
### Open foundation
|
|
||||||
|
|
||||||
- manifest schema and composition;
|
|
||||||
- local CLI and core orchestrator;
|
|
||||||
- provider adapters;
|
|
||||||
- Firecracker/Apple Container/Docker backends;
|
|
||||||
- gateway policy format and enforcement;
|
|
||||||
- Sandbox Agent compatibility;
|
|
||||||
- local audit and supervision; and
|
|
||||||
- conformance tests for providers/backends/policy.
|
|
||||||
|
|
||||||
### Productizable ecosystem/DX
|
|
||||||
|
|
||||||
- polished desktop and mobile clients;
|
|
||||||
- fleet/remote-host management;
|
|
||||||
- signed and curated role/image/policy registry;
|
|
||||||
- team policy distribution and administrative controls;
|
|
||||||
- durable searchable transcripts and audit exports;
|
|
||||||
- SSO, RBAC, retention, and tamper-evident audit;
|
|
||||||
- managed update/compatibility channels;
|
|
||||||
- remote browser/preview relay;
|
|
||||||
- enterprise support; and
|
|
||||||
- optional managed build/cache infrastructure.
|
|
||||||
|
|
||||||
OCI itself is not the thing Docker sells. Interoperability expands the market;
|
|
||||||
the product captures value through reliable packaging, workflow, distribution,
|
|
||||||
management, and trust. bot-bottle should follow that logic rather than trying
|
|
||||||
to make its session protocol the moat.
|
|
||||||
|
|
||||||
## Strategic threat from Sandbox Agent
|
|
||||||
|
|
||||||
Sandbox Agent is a real threat for three reasons:
|
|
||||||
|
|
||||||
1. **It can become the integration default.** A frontend or agent platform can
|
|
||||||
integrate one API and choose among many agents and sandbox vendors.
|
|
||||||
2. **It can own session data and UI.** The universal event schema, Inspector,
|
|
||||||
React components, restoration, terminal, and computer-use APIs give it a
|
|
||||||
natural path toward the operator surface.
|
|
||||||
3. **Sandbox providers can move upward.** If E2B, Daytona, BoxLite, or another
|
|
||||||
runtime combines Sandbox Agent with adequate network policy and credential
|
|
||||||
custody, it can offer much of the turnkey stack.
|
|
||||||
|
|
||||||
The threat is not that its manifest syntax is better. It currently has no
|
|
||||||
equivalent trusted policy composition. The threat is that **the ecosystem may
|
|
||||||
standardize around its API before bot-bottle has a stable external control
|
|
||||||
surface**. In that world bot-bottle is evaluated as one sandbox provider,
|
|
||||||
while the SDK and its consumers own the user relationship.
|
|
||||||
|
|
||||||
## Why bot-bottle can still win its layer
|
|
||||||
|
|
||||||
Sandbox Agent's scope exclusions align with bot-bottle's deepest work:
|
|
||||||
|
|
||||||
- it does not choose or operate the sandbox provider;
|
|
||||||
- it does not mediate Git;
|
|
||||||
- it does not own network policy;
|
|
||||||
- it does not securely deliver credentials;
|
|
||||||
- it does not durably store sessions; and
|
|
||||||
- it cannot make guest-generated telemetry authoritative.
|
|
||||||
|
|
||||||
Those are not incidental features. Together they define the trusted system
|
|
||||||
around an untrusted coding agent. bot-bottle also has a narrower and coherent
|
|
||||||
initial customer: a developer or small operator who wants existing agent CLIs
|
|
||||||
to run locally with broad permissions and bounded consequences.
|
|
||||||
|
|
||||||
The durable advantage is therefore:
|
|
||||||
|
|
||||||
> Sandbox Agent makes agents controllable. bot-bottle makes them safe and
|
|
||||||
> operable.
|
|
||||||
|
|
||||||
That sentence remains true only if bot-bottle closes its operator-DX gaps.
|
|
||||||
Security without a browser/preview loop, stable API, normalized session view,
|
|
||||||
and good parallel-task UX risks becoming an invisible backend feature.
|
|
||||||
|
|
||||||
## Integration options
|
|
||||||
|
|
||||||
### Option A — Embed Sandbox Agent inside each bottle
|
|
||||||
|
|
||||||
bot-bottle launches Sandbox Agent as the provider process supervisor and
|
|
||||||
connects it to the host orchestrator through a bottle-scoped authenticated
|
|
||||||
channel.
|
|
||||||
|
|
||||||
**Benefits**
|
|
||||||
|
|
||||||
- immediate provider-neutral session API;
|
|
||||||
- more supported agents;
|
|
||||||
- normalized streaming and transcripts;
|
|
||||||
- terminal, filesystem, process, and computer-use primitives;
|
|
||||||
- Inspector/React ecosystem; and
|
|
||||||
- less provider-specific reverse engineering in bot-bottle.
|
|
||||||
|
|
||||||
**Risks**
|
|
||||||
|
|
||||||
- `0.x` API/schema churn;
|
|
||||||
- extra binary and release-supply-chain dependency;
|
|
||||||
- lowest-common-denominator normalization;
|
|
||||||
- conflict with provider-native resume state;
|
|
||||||
- an in-guest daemon is attacker-controlled after guest compromise;
|
|
||||||
- duplicate orchestration responsibilities; and
|
|
||||||
- upstream can move into policy/lifecycle and compete more directly.
|
|
||||||
|
|
||||||
**Security rule**
|
|
||||||
|
|
||||||
Treat every event and state claim from Sandbox Agent as untrusted telemetry.
|
|
||||||
Never delegate egress authorization, credential release, bottle identity,
|
|
||||||
authoritative audit, or Git policy to it.
|
|
||||||
|
|
||||||
### Option B — Implement a Sandbox Agent-compatible endpoint
|
|
||||||
|
|
||||||
bot-bottle maps the external protocol onto its existing provider adapters and
|
|
||||||
process model without running the upstream server.
|
|
||||||
|
|
||||||
**Benefits**
|
|
||||||
|
|
||||||
- ecosystem compatibility with tighter component control;
|
|
||||||
- no in-guest daemon dependency; and
|
|
||||||
- room to preserve bot-bottle-native lifecycle semantics.
|
|
||||||
|
|
||||||
**Risks**
|
|
||||||
|
|
||||||
- large and continuing compatibility burden;
|
|
||||||
- “full feature coverage” is expensive across all providers;
|
|
||||||
- accidental protocol fork; and
|
|
||||||
- effort diverted from policy and UX differentiation.
|
|
||||||
|
|
||||||
### Option C — Define an independent bot-bottle session API
|
|
||||||
|
|
||||||
Build only the control surface bot-bottle needs.
|
|
||||||
|
|
||||||
**Benefits**
|
|
||||||
|
|
||||||
- clean fit with the trust model and persistent named bottles;
|
|
||||||
- no upstream dependency; and
|
|
||||||
- deliberate support for supervision and security events.
|
|
||||||
|
|
||||||
**Risks**
|
|
||||||
|
|
||||||
- recreates a fast-growing open-source project;
|
|
||||||
- no existing client ecosystem;
|
|
||||||
- slower browser/desktop/mobile work; and
|
|
||||||
- increases the chance that Sandbox Agent becomes the de facto standard first.
|
|
||||||
|
|
||||||
### Recommendation
|
|
||||||
|
|
||||||
Start with **Option A as a bounded compatibility spike**, not a product
|
|
||||||
commitment. Do not begin with a clean-room competing protocol.
|
|
||||||
|
|
||||||
The spike should answer:
|
|
||||||
|
|
||||||
1. Can Claude Code, Codex, and Pi retain exact native resume behavior?
|
|
||||||
2. Can Sandbox Agent run without receiving real provider credentials?
|
|
||||||
3. Can its server be reached through a bottle-scoped authenticated channel
|
|
||||||
without exposing the orchestrator or broadening guest egress?
|
|
||||||
4. Which permission events overlap or conflict with bot-bottle supervision?
|
|
||||||
5. Can normalized events be stored while clearly separating untrusted
|
|
||||||
transcript telemetry from authoritative gateway/Git audit?
|
|
||||||
6. Can manifest skills, MCP servers, prompt, and startup arguments compile
|
|
||||||
deterministically into its configuration?
|
|
||||||
7. Does its versioning policy permit a compatibility contract bot-bottle can
|
|
||||||
support?
|
|
||||||
8. What image-size, startup-time, and update burden does the binary add?
|
|
||||||
|
|
||||||
If the answers are favorable, adopt it behind a bot-bottle-owned interface and
|
|
||||||
pin/test the supported version. If not, implement the smallest compatible
|
|
||||||
subset needed by external clients before inventing a wholly separate API.
|
|
||||||
|
|
||||||
## Product roadmap implications
|
|
||||||
|
|
||||||
The competitor scan and this architecture comparison reorder the likely work:
|
|
||||||
|
|
||||||
1. **Provider-neutral control/session compatibility spike**
|
|
||||||
2. **Stable authenticated external bot-bottle API**
|
|
||||||
3. **Normalized transcript/event persistence**
|
|
||||||
4. **Parallel-session operator UI**
|
|
||||||
5. **Browser/preview/computer-use capability**
|
|
||||||
6. **Policy/image/skill distribution and signing**
|
|
||||||
7. **Remote host/fleet management**
|
|
||||||
|
|
||||||
This does not mean pausing security work. It means exposing the shipped
|
|
||||||
security work through a product surface that can compete with the SDK-plus-
|
|
||||||
sandbox ecosystem.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Treat Sandbox Agent SDK as a potentially standard **agent process-control
|
|
||||||
layer**, not as a sandbox replacement and not as a minor complementary
|
|
||||||
library. Position bot-bottle one layer above it:
|
|
||||||
|
|
||||||
- manifests express trusted role and environment policy;
|
|
||||||
- bot-bottle compiles and enforces that policy across host, gateway, Git, and
|
|
||||||
isolation backends;
|
|
||||||
- Sandbox Agent or a compatible protocol controls the selected agent process;
|
|
||||||
and
|
|
||||||
- bot-bottle owns the turnkey operator experience.
|
|
||||||
|
|
||||||
The Docker analogy is strategically sound when stated as:
|
|
||||||
|
|
||||||
> Sandbox Agent can be the portable task/exec protocol; bot-bottle can be the
|
|
||||||
> opinionated engine, Compose-like policy layer, and Desktop-like operator
|
|
||||||
> product.
|
|
||||||
|
|
||||||
It is not sound when stated as:
|
|
||||||
|
|
||||||
> Sandbox Agent is OCI and bot-bottle is Docker.
|
|
||||||
|
|
||||||
There is no independent OCI-equivalent agent specification yet, and Sandbox
|
|
||||||
Agent already reaches into UI/session territory. Compatibility should be
|
|
||||||
pursued quickly, while the trusted manifest/enforcement plane and operator
|
|
||||||
experience remain the parts bot-bottle deliberately owns.
|
|
||||||
@@ -18,6 +18,7 @@ from bot_bottle.backend.docker.compose import (
|
|||||||
list_compose_projects,
|
list_compose_projects,
|
||||||
slug_from_compose_project,
|
slug_from_compose_project,
|
||||||
)
|
)
|
||||||
|
from bot_bottle.backend import EnumerationError
|
||||||
|
|
||||||
|
|
||||||
class TestProjectNaming(unittest.TestCase):
|
class TestProjectNaming(unittest.TestCase):
|
||||||
@@ -69,6 +70,19 @@ class TestComposeProjectListing(unittest.TestCase):
|
|||||||
self.assertEqual([], list_active_slugs(warn_on_error=False))
|
self.assertEqual([], list_active_slugs(warn_on_error=False))
|
||||||
warn.assert_not_called()
|
warn.assert_not_called()
|
||||||
|
|
||||||
|
def test_compose_ls_error_can_be_raised_for_enumeration(self):
|
||||||
|
with mock.patch(
|
||||||
|
"bot_bottle.backend.docker.compose.subprocess.run",
|
||||||
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["docker"], returncode=1, stdout="", stderr="no daemon",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(EnumerationError, "no daemon"):
|
||||||
|
list_active_slugs(
|
||||||
|
warn_on_error=False,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
|||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
from bot_bottle.backend.docker.consolidated_launch import (
|
from bot_bottle.backend.docker.consolidated_launch import (
|
||||||
|
ConsolidatedLaunchError,
|
||||||
|
_network_container_ips,
|
||||||
launch_consolidated,
|
launch_consolidated,
|
||||||
deprovision_consolidated,
|
deprovision_consolidated,
|
||||||
)
|
)
|
||||||
@@ -86,6 +88,16 @@ class TestLaunchConsolidated(unittest.TestCase):
|
|||||||
client.teardown_bottle.assert_called_once_with("b1") # no orphan left
|
client.teardown_bottle.assert_called_once_with("b1") # no orphan left
|
||||||
|
|
||||||
|
|
||||||
|
class TestNetworkContainerIps(unittest.TestCase):
|
||||||
|
def test_fails_closed_when_network_inspection_fails(self) -> None:
|
||||||
|
result = Mock(returncode=1, stdout="", stderr="daemon unavailable")
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.run_docker", return_value=result),
|
||||||
|
self.assertRaisesRegex(ConsolidatedLaunchError, "daemon unavailable"),
|
||||||
|
):
|
||||||
|
_network_container_ips("bot-bottle-gateway")
|
||||||
|
|
||||||
|
|
||||||
class TestTeardownConsolidated(unittest.TestCase):
|
class TestTeardownConsolidated(unittest.TestCase):
|
||||||
def test_deregisters_and_deprovisions(self) -> None:
|
def test_deregisters_and_deprovisions(self) -> None:
|
||||||
client = Mock()
|
client = Mock()
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ of issue #77 — the dashboard now delegates to this layer.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
from bot_bottle import bottle_state
|
from bot_bottle import bottle_state
|
||||||
from bot_bottle.backend.docker import enumerate as _enumerate
|
from bot_bottle.backend.docker import enumerate as _enumerate
|
||||||
|
from bot_bottle.backend import EnumerationError
|
||||||
|
|
||||||
|
|
||||||
class TestParseServicesByProject(unittest.TestCase):
|
class TestParseServicesByProject(unittest.TestCase):
|
||||||
@@ -72,6 +75,18 @@ class TestParseServicesByProject(unittest.TestCase):
|
|||||||
self.assertEqual({"bot-bottle-dev-abc": {"egress"}}, out)
|
self.assertEqual({"bot-bottle-dev-abc": {"egress"}}, out)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryServicesByProject(unittest.TestCase):
|
||||||
|
def test_docker_ps_failure_is_not_an_empty_result(self):
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.docker.enumerate.subprocess.run",
|
||||||
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["docker"], returncode=1, stdout="", stderr="daemon down",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(EnumerationError, "daemon down"):
|
||||||
|
_enumerate._query_services_by_project()
|
||||||
|
|
||||||
|
|
||||||
class _FakeHomeMixin:
|
class _FakeHomeMixin:
|
||||||
def _setup_fake_home(self) -> None:
|
def _setup_fake_home(self) -> None:
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle.backend import EnumerationError
|
||||||
from bot_bottle.backend.firecracker import cleanup as fc_cleanup
|
from bot_bottle.backend.firecracker import cleanup as fc_cleanup
|
||||||
from bot_bottle.backend.firecracker.bottle_cleanup_plan import (
|
from bot_bottle.backend.firecracker.bottle_cleanup_plan import (
|
||||||
FirecrackerBottleCleanupPlan,
|
FirecrackerBottleCleanupPlan,
|
||||||
@@ -58,10 +59,37 @@ class TestProcessScan(unittest.TestCase):
|
|||||||
self.assertEqual({str(run_root / "live-a")}, live)
|
self.assertEqual({str(run_root / "live-a")}, live)
|
||||||
self.assertEqual([222], orphan_pids)
|
self.assertEqual([222], orphan_pids)
|
||||||
|
|
||||||
def test_scan_empty_when_pgrep_fails(self):
|
def test_scan_empty_when_pgrep_finds_no_processes(self):
|
||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
||||||
self.assertEqual((set(), []), fc_cleanup._scan_processes(Path("/x")))
|
self.assertEqual((set(), []), fc_cleanup._scan_processes(Path("/x")))
|
||||||
|
|
||||||
|
def test_scan_raises_when_pgrep_errors(self):
|
||||||
|
proc = subprocess.CompletedProcess(
|
||||||
|
[], 2, stdout="", stderr="invalid process expression",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(fc_cleanup.subprocess, "run", return_value=proc),
|
||||||
|
self.assertRaisesRegex(EnumerationError, "invalid process expression"),
|
||||||
|
):
|
||||||
|
fc_cleanup._scan_processes(Path("/x"))
|
||||||
|
|
||||||
|
def test_prepare_cleanup_does_not_plan_deletions_when_scan_errors(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
run_root = Path(tmp)
|
||||||
|
live = run_root / "live-a"
|
||||||
|
live.mkdir()
|
||||||
|
with (
|
||||||
|
patch.object(fc_cleanup, "_run_root", return_value=run_root),
|
||||||
|
patch.object(
|
||||||
|
fc_cleanup.subprocess,
|
||||||
|
"run",
|
||||||
|
return_value=_proc(returncode=2),
|
||||||
|
),
|
||||||
|
self.assertRaises(EnumerationError),
|
||||||
|
):
|
||||||
|
fc_cleanup.prepare_cleanup()
|
||||||
|
self.assertTrue(live.is_dir())
|
||||||
|
|
||||||
def test_live_run_dirs_returns_paths_in_stable_order(self):
|
def test_live_run_dirs_returns_paths_in_stable_order(self):
|
||||||
with patch.object(fc_cleanup, "_run_root", return_value=Path("/run")), \
|
with patch.object(fc_cleanup, "_run_root", return_value=Path("/run")), \
|
||||||
patch.object(fc_cleanup, "_scan_processes",
|
patch.object(fc_cleanup, "_scan_processes",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Unit tests for Firecracker active-agent enumeration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle.backend import EnumerationError
|
||||||
|
from bot_bottle.backend.firecracker import enumerate as fc_enumerate
|
||||||
|
from bot_bottle.bottle_state import BottleMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumerateActive(unittest.TestCase):
|
||||||
|
def test_maps_live_run_dirs_to_active_agents(self) -> None:
|
||||||
|
metadata = BottleMetadata(
|
||||||
|
identity="dev-a",
|
||||||
|
agent_name="claude",
|
||||||
|
cwd="",
|
||||||
|
copy_cwd=False,
|
||||||
|
started_at="2026-07-26T12:00:00Z",
|
||||||
|
label="review",
|
||||||
|
color="blue",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
fc_enumerate, "live_run_dirs",
|
||||||
|
return_value=(Path("/cache/run/dev-a"),),
|
||||||
|
),
|
||||||
|
patch.object(fc_enumerate, "read_metadata", return_value=metadata),
|
||||||
|
):
|
||||||
|
agents = fc_enumerate.enumerate_active()
|
||||||
|
self.assertEqual(1, len(agents))
|
||||||
|
self.assertEqual("firecracker", agents[0].backend_name)
|
||||||
|
self.assertEqual("dev-a", agents[0].slug)
|
||||||
|
self.assertEqual("claude", agents[0].agent_name)
|
||||||
|
self.assertEqual("review", agents[0].label)
|
||||||
|
self.assertEqual((), agents[0].services)
|
||||||
|
|
||||||
|
def test_missing_metadata_uses_safe_defaults(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
fc_enumerate, "live_run_dirs",
|
||||||
|
return_value=(Path("/cache/run/dev-a"),),
|
||||||
|
),
|
||||||
|
patch.object(fc_enumerate, "read_metadata", return_value=None),
|
||||||
|
):
|
||||||
|
agent = fc_enumerate.enumerate_active()[0]
|
||||||
|
self.assertEqual("?", agent.agent_name)
|
||||||
|
self.assertEqual("", agent.started_at)
|
||||||
|
|
||||||
|
def test_process_scan_failure_propagates(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
fc_enumerate, "live_run_dirs",
|
||||||
|
side_effect=EnumerationError("pgrep failed"),
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(EnumerationError, "pgrep failed"),
|
||||||
|
):
|
||||||
|
fc_enumerate.enumerate_active()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle.backend import EnumerationError
|
||||||
from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod
|
from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod
|
||||||
from bot_bottle.backend.macos_container.bottle_cleanup_plan import (
|
from bot_bottle.backend.macos_container.bottle_cleanup_plan import (
|
||||||
MacosContainerBottleCleanupPlan,
|
MacosContainerBottleCleanupPlan,
|
||||||
@@ -69,10 +70,18 @@ class TestMacosContainerEnumerate(unittest.TestCase):
|
|||||||
self.assertEqual(["dev-abc"], [a.slug for a in agents])
|
self.assertEqual(["dev-abc"], [a.slug for a in agents])
|
||||||
|
|
||||||
def test_raises_when_the_cli_fails(self):
|
def test_raises_when_the_cli_fails(self):
|
||||||
from bot_bottle.backend.macos_container.enumerate import EnumerationError
|
|
||||||
with self.assertRaises(EnumerationError):
|
with self.assertRaises(EnumerationError):
|
||||||
self._enumerate("", returncode=1)
|
self._enumerate("", returncode=1)
|
||||||
|
|
||||||
|
def test_raises_typed_error_when_cli_is_missing(self):
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
enum_mod.subprocess, "run", side_effect=FileNotFoundError,
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(EnumerationError, "CLI not found"),
|
||||||
|
):
|
||||||
|
enum_mod.enumerate_active()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.orchestrator.store.secret_store import (
|
from bot_bottle.orchestrator.store.secret_store import (
|
||||||
@@ -68,15 +71,28 @@ class TestDecryptErrors(unittest.TestCase):
|
|||||||
def test_wrong_key_raises_value_error(self) -> None:
|
def test_wrong_key_raises_value_error(self) -> None:
|
||||||
ct = encrypt_value(self.secret, "secret-token")
|
ct = encrypt_value(self.secret, "secret-token")
|
||||||
other_key = new_env_var_secret()
|
other_key = new_env_var_secret()
|
||||||
# Wrong key produces garbage bytes; decrypt_value raises ValueError
|
with self.assertRaisesRegex(ValueError, "authentication failed"):
|
||||||
# when the result is non-UTF-8 (which is very likely for 12-char data).
|
decrypt_value(other_key, ct)
|
||||||
# We allow it to succeed only if garbage happens to be valid UTF-8, but
|
|
||||||
# the plaintext must not match.
|
def test_tampered_ciphertext_raises_value_error(self) -> None:
|
||||||
try:
|
raw = bytearray(base64.urlsafe_b64decode(
|
||||||
result = decrypt_value(other_key, ct)
|
encrypt_value(self.secret, "secret-token") + "=="
|
||||||
self.assertNotEqual("secret-token", result)
|
))
|
||||||
except ValueError:
|
raw[22] ^= 1
|
||||||
pass
|
tampered = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||||
|
with self.assertRaisesRegex(ValueError, "authentication failed"):
|
||||||
|
decrypt_value(self.secret, tampered)
|
||||||
|
|
||||||
|
def test_reads_legacy_ciphertext_for_migration(self) -> None:
|
||||||
|
key = base64.urlsafe_b64decode(self.secret + "==")
|
||||||
|
nonce = b"0123456789abcdef"
|
||||||
|
plaintext = b"legacy-token"
|
||||||
|
stream = hmac.new(
|
||||||
|
key, nonce + (0).to_bytes(4, "big"), hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
ciphertext = bytes(p ^ k for p, k in zip(plaintext, stream))
|
||||||
|
legacy = base64.urlsafe_b64encode(nonce + ciphertext).rstrip(b"=").decode()
|
||||||
|
self.assertEqual("legacy-token", decrypt_value(self.secret, legacy))
|
||||||
|
|
||||||
def test_truncated_blob_raises_value_error(self) -> None:
|
def test_truncated_blob_raises_value_error(self) -> None:
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import http.client
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
@@ -22,7 +23,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
||||||
from bot_bottle.orchestrator.broker import StubBroker
|
from bot_bottle.orchestrator.broker import StubBroker
|
||||||
from bot_bottle.orchestrator.server import dispatch, make_server
|
from bot_bottle.orchestrator.server import MAX_BODY_BYTES, dispatch, make_server
|
||||||
from bot_bottle.orchestrator.store.registry_store import BottleRecord, RegistryStore
|
from bot_bottle.orchestrator.store.registry_store import BottleRecord, RegistryStore
|
||||||
from bot_bottle.orchestrator.service import OrchestratorCore
|
from bot_bottle.orchestrator.service import OrchestratorCore
|
||||||
from bot_bottle.orchestrator.store.store_manager import StoreManager
|
from bot_bottle.orchestrator.store.store_manager import StoreManager
|
||||||
@@ -251,11 +252,51 @@ class TestDispatch(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestServerRoundTrip(unittest.TestCase):
|
class TestServerRoundTrip(unittest.TestCase):
|
||||||
|
def _raw_status(self, content_length: str, *, authenticated: bool = True) -> int:
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
key = "request-limits-key"
|
||||||
|
server = make_server(
|
||||||
|
_orchestrator(Path(tmp.name) / "r.db"),
|
||||||
|
"127.0.0.1", 0, signing_key=key,
|
||||||
|
)
|
||||||
|
self.addCleanup(server.server_close)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
self.addCleanup(server.shutdown)
|
||||||
|
conn = http.client.HTTPConnection(
|
||||||
|
str(server.server_address[0]), server.server_address[1], timeout=5,
|
||||||
|
)
|
||||||
|
self.addCleanup(conn.close)
|
||||||
|
conn.putrequest("POST", "/bottles")
|
||||||
|
conn.putheader("Content-Length", content_length)
|
||||||
|
if authenticated:
|
||||||
|
conn.putheader(
|
||||||
|
"x-bot-bottle-orchestrator-auth", mint(ROLE_CLI, key),
|
||||||
|
)
|
||||||
|
conn.endheaders()
|
||||||
|
return conn.getresponse().status
|
||||||
|
|
||||||
|
def test_rejects_malformed_content_length(self) -> None:
|
||||||
|
self.assertEqual(400, self._raw_status("not-a-number"))
|
||||||
|
|
||||||
|
def test_rejects_oversized_body_without_reading_it(self) -> None:
|
||||||
|
self.assertEqual(413, self._raw_status(str(MAX_BODY_BYTES + 1)))
|
||||||
|
|
||||||
|
def test_rejects_unauthenticated_request_before_reading_body(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
401,
|
||||||
|
self._raw_status(str(MAX_BODY_BYTES), authenticated=False),
|
||||||
|
)
|
||||||
|
|
||||||
def test_http_register_health_attribute(self) -> None:
|
def test_http_register_health_attribute(self) -> None:
|
||||||
tmp = tempfile.TemporaryDirectory()
|
tmp = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(tmp.cleanup)
|
self.addCleanup(tmp.cleanup)
|
||||||
orch = _orchestrator(Path(tmp.name) / "r.db")
|
orch = _orchestrator(Path(tmp.name) / "r.db")
|
||||||
server = make_server(orch, "127.0.0.1", 0)
|
signing_key = "round-trip-key"
|
||||||
|
auth = mint(ROLE_CLI, signing_key)
|
||||||
|
server = make_server(
|
||||||
|
orch, "127.0.0.1", 0, signing_key=signing_key,
|
||||||
|
)
|
||||||
self.addCleanup(server.server_close)
|
self.addCleanup(server.server_close)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -267,7 +308,10 @@ class TestServerRoundTrip(unittest.TestCase):
|
|||||||
reg = json.load(urllib.request.urlopen(
|
reg = json.load(urllib.request.urlopen(
|
||||||
urllib.request.Request(
|
urllib.request.Request(
|
||||||
f"{base}/bottles", data=_body({"source_ip": "10.243.0.7"}),
|
f"{base}/bottles", data=_body({"source_ip": "10.243.0.7"}),
|
||||||
method="POST", headers={"Content-Type": "application/json"},
|
method="POST", headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-bot-bottle-orchestrator-auth": auth,
|
||||||
|
},
|
||||||
), timeout=5,
|
), timeout=5,
|
||||||
))
|
))
|
||||||
self.assertTrue(reg["bottle_id"])
|
self.assertTrue(reg["bottle_id"])
|
||||||
@@ -279,7 +323,10 @@ class TestServerRoundTrip(unittest.TestCase):
|
|||||||
urllib.request.Request(
|
urllib.request.Request(
|
||||||
f"{base}/attribute",
|
f"{base}/attribute",
|
||||||
data=_body({"source_ip": "10.243.0.7", "identity_token": reg["identity_token"]}),
|
data=_body({"source_ip": "10.243.0.7", "identity_token": reg["identity_token"]}),
|
||||||
method="POST", headers={"Content-Type": "application/json"},
|
method="POST", headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-bot-bottle-orchestrator-auth": auth,
|
||||||
|
},
|
||||||
), timeout=5,
|
), timeout=5,
|
||||||
))
|
))
|
||||||
self.assertEqual(reg["bottle_id"], attr["bottle_id"])
|
self.assertEqual(reg["bottle_id"], attr["bottle_id"])
|
||||||
@@ -287,15 +334,25 @@ class TestServerRoundTrip(unittest.TestCase):
|
|||||||
def test_internal_failure_is_contextual_but_redacted(self) -> None:
|
def test_internal_failure_is_contextual_but_redacted(self) -> None:
|
||||||
orch = MagicMock()
|
orch = MagicMock()
|
||||||
orch.registry.all.side_effect = RuntimeError("SENSITIVE request value")
|
orch.registry.all.side_effect = RuntimeError("SENSITIVE request value")
|
||||||
|
signing_key = "failure-path-key"
|
||||||
|
auth = mint(ROLE_CLI, signing_key)
|
||||||
with patch("sys.stderr", io.StringIO()) as stderr:
|
with patch("sys.stderr", io.StringIO()) as stderr:
|
||||||
server = make_server(orch, "127.0.0.1", 0)
|
server = make_server(
|
||||||
|
orch, "127.0.0.1", 0, signing_key=signing_key,
|
||||||
|
)
|
||||||
self.addCleanup(server.server_close)
|
self.addCleanup(server.server_close)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
self.addCleanup(server.shutdown)
|
self.addCleanup(server.shutdown)
|
||||||
host, port = server.server_address[0], server.server_address[1]
|
host, port = server.server_address[0], server.server_address[1]
|
||||||
with self.assertRaises(urllib.error.HTTPError) as raised:
|
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||||
urllib.request.urlopen(f"http://{host}:{port}/bottles", timeout=5)
|
urllib.request.urlopen(
|
||||||
|
urllib.request.Request(
|
||||||
|
f"http://{host}:{port}/bottles",
|
||||||
|
headers={"x-bot-bottle-orchestrator-auth": auth},
|
||||||
|
),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
payload = json.loads(raised.exception.read())
|
payload = json.loads(raised.exception.read())
|
||||||
output = stderr.getvalue()
|
output = stderr.getvalue()
|
||||||
self.assertEqual({"error": "internal error"}, payload)
|
self.assertEqual({"error": "internal error"}, payload)
|
||||||
@@ -369,8 +426,9 @@ class TestOrchestratorAuth(unittest.TestCase):
|
|||||||
self.assertIsNotNone(self.orch.registry.get(rec.bottle_id))
|
self.assertIsNotNone(self.orch.registry.get(rec.bottle_id))
|
||||||
|
|
||||||
def _server_with_key(self, signing_key: str):
|
def _server_with_key(self, signing_key: str):
|
||||||
with patch.dict("os.environ", {"BOT_BOTTLE_ORCHESTRATOR_TOKEN": signing_key}):
|
server = make_server(
|
||||||
server = make_server(self.orch, "127.0.0.1", 0)
|
self.orch, "127.0.0.1", 0, signing_key=signing_key,
|
||||||
|
)
|
||||||
self.addCleanup(server.server_close)
|
self.addCleanup(server.server_close)
|
||||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
self.addCleanup(server.shutdown)
|
self.addCleanup(server.shutdown)
|
||||||
@@ -399,16 +457,10 @@ class TestOrchestratorAuth(unittest.TestCase):
|
|||||||
self.assertEqual(403, self._status(f"{base}/bottles", header=gateway_tok))
|
self.assertEqual(403, self._status(f"{base}/bottles", header=gateway_tok))
|
||||||
self.assertEqual(200, self._status(f"{base}/bottles", header=cli_tok))
|
self.assertEqual(200, self._status(f"{base}/bottles", header=cli_tok))
|
||||||
|
|
||||||
def test_unconfigured_server_runs_open(self) -> None:
|
def test_unconfigured_server_refuses_to_start(self) -> None:
|
||||||
"""No signing key set (tests / nft-protected Firecracker): open mode
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
grants full cli access, so existing round-trip behavior is unchanged."""
|
with self.assertRaisesRegex(ValueError, "signing key is required"):
|
||||||
with patch.dict("os.environ", {}, clear=False):
|
make_server(self.orch, "127.0.0.1", 0)
|
||||||
import os
|
|
||||||
os.environ.pop("BOT_BOTTLE_ORCHESTRATOR_TOKEN", None)
|
|
||||||
server = make_server(self.orch, "127.0.0.1", 0)
|
|
||||||
self.addCleanup(server.server_close)
|
|
||||||
self.assertEqual(ROLE_CLI, server.role_for(""))
|
|
||||||
self.assertEqual(ROLE_CLI, server.role_for("anything"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestDispatchSupervise(unittest.TestCase):
|
class TestDispatchSupervise(unittest.TestCase):
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ class TestControlPlaneProvisioning(unittest.TestCase):
|
|||||||
self.assertEqual("key", prov.orchestrator_key())
|
self.assertEqual("key", prov.orchestrator_key())
|
||||||
|
|
||||||
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
|
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
|
||||||
# Invariant 4: the orchestrator must never start without a key — it would
|
# Invariant 4: the orchestrator must never start without a key. There is no
|
||||||
# run OPEN and grant every caller that reaches it full `cli`. There is no
|
|
||||||
# topology opt-out: a separate host does not stop the gateway (or any
|
# topology opt-out: a separate host does not stop the gateway (or any
|
||||||
# other caller) from reaching the control-plane listener.
|
# other caller) from reaching the control-plane listener.
|
||||||
prov = ControlPlaneProvisioning()
|
prov = ControlPlaneProvisioning()
|
||||||
|
|||||||
Reference in New Issue
Block a user