Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 031bc6301e | |||
| aaf1e60abe | |||
| 38a67d2767 | |||
| dbbb185d0a | |||
| 95cbd0e1c8 | |||
| f242733d15 | |||
| fd295d4c14 | |||
| f33566941b | |||
| c7c3a79028 | |||
| 82e1a353bd | |||
| a6e1aebda1 |
@@ -172,6 +172,10 @@ class BottleCleanupPlan(ABC):
|
|||||||
"""True iff there is nothing to clean up; the CLI uses this to
|
"""True iff there is nothing to clean up; the CLI uses this to
|
||||||
short-circuit before showing the y/N."""
|
short-circuit before showing the y/N."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def intersect(self, current: "BottleCleanupPlan") -> "BottleCleanupPlan":
|
||||||
|
"""Resources both displayed to the operator and currently removable."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExecResult:
|
class ExecResult:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Shared destructive-cleanup execution and failure accounting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupError(RuntimeError):
|
||||||
|
"""One or more approved cleanup mutations did not complete."""
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupFailures:
|
||||||
|
"""Attempt every approved mutation, then fail with complete diagnostics."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._messages: list[str] = []
|
||||||
|
|
||||||
|
def run(self, argv: Sequence[str], description: str) -> None:
|
||||||
|
raw_timeout = os.environ.get(
|
||||||
|
"BOT_BOTTLE_CLEANUP_COMMAND_TIMEOUT_SECONDS", "120",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
timeout = float(raw_timeout)
|
||||||
|
except ValueError:
|
||||||
|
timeout = 120.0
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
list(argv), capture_output=True, text=True, check=False,
|
||||||
|
timeout=max(timeout, 1.0),
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
self._messages.append(f"{description}: {exc}")
|
||||||
|
return
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
self._messages.append(
|
||||||
|
f"{description}: {detail or f'exit {result.returncode}'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_tree(self, path: Path, description: str) -> None:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
self._messages.append(f"{description}: {exc}")
|
||||||
|
|
||||||
|
def record(self, message: str) -> None:
|
||||||
|
self._messages.append(message)
|
||||||
|
|
||||||
|
def raise_if_any(self) -> None:
|
||||||
|
if self._messages:
|
||||||
|
raise CleanupError("; ".join(self._messages))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CleanupError", "CleanupFailures"]
|
||||||
@@ -46,6 +46,22 @@ class DockerBottleCleanupPlan(BottleCleanupPlan):
|
|||||||
and not self.orphan_state_dirs
|
and not self.orphan_state_dirs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def intersect(self, current: BottleCleanupPlan) -> "DockerBottleCleanupPlan":
|
||||||
|
if not isinstance(current, DockerBottleCleanupPlan):
|
||||||
|
raise TypeError("cleanup plans must have the same backend type")
|
||||||
|
return DockerBottleCleanupPlan(
|
||||||
|
projects=tuple(x for x in self.projects if x in current.projects),
|
||||||
|
stray_containers=tuple(
|
||||||
|
x for x in self.stray_containers if x in current.stray_containers
|
||||||
|
),
|
||||||
|
stray_networks=tuple(
|
||||||
|
x for x in self.stray_networks if x in current.stray_networks
|
||||||
|
),
|
||||||
|
orphan_state_dirs=tuple(
|
||||||
|
x for x in self.orphan_state_dirs if x in current.orphan_state_dirs
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def print(self) -> None:
|
def print(self) -> None:
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
for name in self.projects:
|
for name in self.projects:
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ Active-agent enumeration lives in `backend/docker/enumerate.py`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from ...paths import bot_bottle_root
|
from ...paths import bot_bottle_root
|
||||||
from ...log import info, warn
|
from ...log import info
|
||||||
|
from .. import EnumerationError
|
||||||
|
from ..cleanup_control import CleanupFailures
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from ...bottle_state import bottle_state_dir, is_preserved
|
from ...bottle_state import bottle_state_dir, is_preserved
|
||||||
@@ -36,15 +37,17 @@ from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
|||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
def _list_prefixed_containers() -> list[str]:
|
||||||
"""All bot-bottle-prefixed containers, running or stopped."""
|
"""All bot-bottle-prefixed containers, running or stopped."""
|
||||||
result = subprocess.run(
|
try:
|
||||||
["docker", "ps", "-a",
|
result = subprocess.run(
|
||||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
["docker", "ps", "-a",
|
||||||
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
||||||
capture_output=True, text=True, check=False,
|
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||||
)
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise EnumerationError(f"docker ps failed: {exc}") from exc
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
warn(f"docker ps failed: {result.stderr.strip()}")
|
raise EnumerationError(f"docker ps failed: {result.stderr.strip()}")
|
||||||
return []
|
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for line in (result.stdout or "").splitlines():
|
for line in (result.stdout or "").splitlines():
|
||||||
if not line:
|
if not line:
|
||||||
@@ -63,15 +66,19 @@ def _list_prefixed_networks() -> list[str]:
|
|||||||
to a compose project. Compose-managed networks have a
|
to a compose project. Compose-managed networks have a
|
||||||
`com.docker.compose.project` label; bare ones (from pre-compose
|
`com.docker.compose.project` label; bare ones (from pre-compose
|
||||||
code paths) don't."""
|
code paths) don't."""
|
||||||
result = subprocess.run(
|
try:
|
||||||
["docker", "network", "ls",
|
result = subprocess.run(
|
||||||
"--filter", f"name={COMPOSE_PROJECT_PREFIX}",
|
["docker", "network", "ls",
|
||||||
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
"--filter", f"name={COMPOSE_PROJECT_PREFIX}",
|
||||||
capture_output=True, text=True, check=False,
|
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||||
)
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise EnumerationError(f"docker network ls failed: {exc}") from exc
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
warn(f"docker network ls failed: {result.stderr.strip()}")
|
raise EnumerationError(
|
||||||
return []
|
f"docker network ls failed: {result.stderr.strip()}"
|
||||||
|
)
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for line in (result.stdout or "").splitlines():
|
for line in (result.stdout or "").splitlines():
|
||||||
if not line:
|
if not line:
|
||||||
@@ -120,7 +127,10 @@ def prepare_cleanup() -> DockerBottleCleanupPlan:
|
|||||||
`enumerate_active_agents()` so the orphan-state-dir bucket
|
`enumerate_active_agents()` so the orphan-state-dir bucket
|
||||||
doesn't include slugs whose non-docker bottle is still up."""
|
doesn't include slugs whose non-docker bottle is still up."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
projects = list_compose_projects()
|
projects = list_compose_projects(
|
||||||
|
warn_on_error=False,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
project_set = set(projects)
|
project_set = set(projects)
|
||||||
# Late import to avoid a circular at module-load time —
|
# Late import to avoid a circular at module-load time —
|
||||||
# the backend package's __init__ imports this module.
|
# the backend package's __init__ imports this module.
|
||||||
@@ -140,40 +150,30 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
|||||||
"""Remove everything in the plan. Projects first (whose `compose
|
"""Remove everything in the plan. Projects first (whose `compose
|
||||||
down` reaps their containers + networks atomically), then stray
|
down` reaps their containers + networks atomically), then stray
|
||||||
legacy resources, then orphan state dirs."""
|
legacy resources, then orphan state dirs."""
|
||||||
|
failures = CleanupFailures()
|
||||||
for project in plan.projects:
|
for project in plan.projects:
|
||||||
info(f"docker compose down ({project})")
|
info(f"docker compose down ({project})")
|
||||||
result = subprocess.run(
|
failures.run(
|
||||||
["docker", "compose", "-p", project, "down", "--volumes"],
|
["docker", "compose", "-p", project, "down", "--volumes"],
|
||||||
capture_output=True, text=True, check=False,
|
f"docker compose down failed for {project}",
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"compose down failed for {project}: "
|
|
||||||
f"{result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in plan.stray_containers:
|
for name in plan.stray_containers:
|
||||||
info(f"removing stray container {name}")
|
info(f"removing stray container {name}")
|
||||||
subprocess.run(
|
failures.run(
|
||||||
["docker", "rm", "-f", name],
|
["docker", "rm", "-f", name],
|
||||||
stdout=subprocess.DEVNULL,
|
f"removing stray container {name}",
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for name in plan.stray_networks:
|
for name in plan.stray_networks:
|
||||||
info(f"removing stray network {name}")
|
info(f"removing stray network {name}")
|
||||||
subprocess.run(
|
failures.run(
|
||||||
["docker", "network", "rm", name],
|
["docker", "network", "rm", name],
|
||||||
stdout=subprocess.DEVNULL,
|
f"removing stray network {name}",
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for identity in plan.orphan_state_dirs:
|
for identity in plan.orphan_state_dirs:
|
||||||
path = bottle_state_dir(identity)
|
path = bottle_state_dir(identity)
|
||||||
info(f"removing orphan state dir {path}")
|
info(f"removing orphan state dir {path}")
|
||||||
try:
|
failures.remove_tree(path, f"removing orphan state dir {path}")
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
failures.raise_if_any()
|
||||||
except OSError as e:
|
|
||||||
warn(f"failed to remove {path}: {e}")
|
|
||||||
|
|||||||
@@ -27,3 +27,11 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
|||||||
@property
|
@property
|
||||||
def empty(self) -> bool:
|
def empty(self) -> bool:
|
||||||
return not (self.vm_pids or self.run_dirs)
|
return not (self.vm_pids or self.run_dirs)
|
||||||
|
|
||||||
|
def intersect(self, current: BottleCleanupPlan) -> "FirecrackerBottleCleanupPlan":
|
||||||
|
if not isinstance(current, FirecrackerBottleCleanupPlan):
|
||||||
|
raise TypeError("cleanup plans must have the same backend type")
|
||||||
|
return FirecrackerBottleCleanupPlan(
|
||||||
|
vm_pids=tuple(x for x in self.vm_pids if x in current.vm_pids),
|
||||||
|
run_dirs=tuple(x for x in self.run_dirs if x in current.run_dirs),
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ Reaps *orphans* only — resources with no live VM behind them:
|
|||||||
— a VMM left lingering after its dir was removed.
|
— a VMM left lingering after its dir was removed.
|
||||||
|
|
||||||
A run dir with a *live* firecracker process is a running bottle and is
|
A run dir with a *live* firecracker process is a running bottle and is
|
||||||
left strictly alone: it is neither killed nor removed. (The backend's
|
left strictly alone: it is neither killed nor removed. Active-agent
|
||||||
`enumerate_active` registry is still a stub — #354 — so a live process
|
enumeration uses this same process snapshot, so cleanup and generic
|
||||||
is the only reliable "this bottle is in use" signal we have. Once the
|
backend consumers agree about which bottles are running.
|
||||||
registry lands, registry-orphaned-but-running VMs can be reaped too.)
|
|
||||||
|
|
||||||
TAP slots free themselves (the flock drops when the launcher exits), so
|
TAP slots free themselves (the flock drops when the launcher exits), so
|
||||||
there is nothing to reclaim there.
|
there is nothing to reclaim there.
|
||||||
@@ -22,15 +21,16 @@ there is nothing to reclaim there.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from .. import EnumerationError
|
from .. import EnumerationError
|
||||||
from . import util
|
from ..cleanup_control import CleanupError, CleanupFailures
|
||||||
|
from . import lifecycle_lock, util
|
||||||
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ def _run_root() -> Path:
|
|||||||
return util.cache_dir() / "run"
|
return util.cache_dir() / "run"
|
||||||
|
|
||||||
|
|
||||||
def _run_dir_of(cmd: str, run_root: Path) -> Path | None:
|
def _run_dir_of(args: Sequence[str], run_root: Path) -> Path | None:
|
||||||
"""The bottle run dir a firecracker cmdline belongs to, or None.
|
"""The bottle run dir a firecracker cmdline belongs to, or None.
|
||||||
|
|
||||||
A bottle VM is launched with `--config-file <run_root>/<slug>/config.json`,
|
A bottle VM is launched with `--config-file <run_root>/<slug>/config.json`,
|
||||||
@@ -46,15 +46,35 @@ def _run_dir_of(cmd: str, run_root: Path) -> Path | None:
|
|||||||
the run root. Anything else (a builder VM, the infra VM elsewhere) is
|
the run root. Anything else (a builder VM, the infra VM elsewhere) is
|
||||||
not ours to reap here.
|
not ours to reap here.
|
||||||
"""
|
"""
|
||||||
toks = cmd.split()
|
for i, arg in enumerate(args):
|
||||||
for i, tok in enumerate(toks):
|
if arg == "--config-file" and i + 1 < len(args):
|
||||||
if tok == "--config-file" and i + 1 < len(toks):
|
parent = Path(args[i + 1]).parent
|
||||||
parent = Path(toks[i + 1]).parent
|
|
||||||
if parent.parent == run_root:
|
if parent.parent == run_root:
|
||||||
return parent
|
return parent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cmdline(raw: bytes) -> tuple[str, ...]:
|
||||||
|
"""Decode Linux's NUL-delimited argv without losing embedded spaces."""
|
||||||
|
return tuple(
|
||||||
|
value.decode(errors="surrogateescape")
|
||||||
|
for value in raw.split(b"\0") if value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_args(pid: int) -> tuple[str, ...] | None:
|
||||||
|
"""Read one process's lossless argv, or None when it exited meanwhile."""
|
||||||
|
try:
|
||||||
|
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
raise EnumerationError(
|
||||||
|
f"could not inspect Firecracker pid {pid}: {exc}"
|
||||||
|
) from exc
|
||||||
|
return _decode_cmdline(raw)
|
||||||
|
|
||||||
|
|
||||||
def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
||||||
"""Inspect running firecracker VMs under ``run_root``.
|
"""Inspect running firecracker VMs under ``run_root``.
|
||||||
|
|
||||||
@@ -65,7 +85,7 @@ def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-a", "firecracker"],
|
["pgrep", "firecracker"],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -83,14 +103,14 @@ def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
|||||||
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():
|
||||||
parts = line.split(None, 1)
|
|
||||||
if len(parts) != 2:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
pid = int(parts[0])
|
pid = int(line.strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
run_dir = _run_dir_of(parts[1], run_root)
|
args = _process_args(pid)
|
||||||
|
if args is None:
|
||||||
|
continue
|
||||||
|
run_dir = _run_dir_of(args, run_root)
|
||||||
if run_dir is None:
|
if run_dir is None:
|
||||||
continue
|
continue
|
||||||
if run_dir.is_dir():
|
if run_dir.is_dir():
|
||||||
@@ -126,12 +146,54 @@ def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
|||||||
|
|
||||||
|
|
||||||
def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
||||||
for pid in plan.vm_pids:
|
"""Revalidate the preview under the launch lock, then remove its survivors."""
|
||||||
|
with lifecycle_lock.hold():
|
||||||
|
fresh = prepare_cleanup()
|
||||||
|
approved_pids = set(plan.vm_pids).intersection(fresh.vm_pids)
|
||||||
|
approved_dirs = set(plan.run_dirs).intersection(fresh.run_dirs)
|
||||||
|
failures = CleanupFailures()
|
||||||
|
for pid in sorted(approved_pids):
|
||||||
|
try:
|
||||||
|
_terminate_orphan(pid, _run_root())
|
||||||
|
except CleanupError as exc:
|
||||||
|
failures.record(str(exc))
|
||||||
|
for path in sorted(approved_dirs):
|
||||||
|
info(f"rm -rf {path}")
|
||||||
|
failures.remove_tree(Path(path), f"removing Firecracker run dir {path}")
|
||||||
|
failures.raise_if_any()
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_orphan(pid: int, run_root: Path) -> None:
|
||||||
|
"""Signal exactly the process identity that still owns an orphan config."""
|
||||||
|
try:
|
||||||
|
pidfd = os.pidfd_open(pid)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
raise EnumerationError(
|
||||||
|
f"could not pin Firecracker pid {pid} for cleanup: {exc}"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
raise EnumerationError(
|
||||||
|
f"could not revalidate Firecracker pid {pid}: {exc}"
|
||||||
|
) from exc
|
||||||
|
args = _decode_cmdline(raw)
|
||||||
|
run_dir = _run_dir_of(args, run_root)
|
||||||
|
if run_dir is None or run_dir.is_dir():
|
||||||
|
return
|
||||||
info(f"kill firecracker VM pid {pid}")
|
info(f"kill firecracker VM pid {pid}")
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
return
|
||||||
for path in plan.run_dirs:
|
except OSError as exc:
|
||||||
info(f"rm -rf {path}")
|
raise CleanupError(
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
f"could not signal Firecracker pid {pid}: {exc}"
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
os.close(pidfd)
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ from ... import resources
|
|||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
|
ARTIFACT_HTTP_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
# Bump if the on-disk artifact *format* changes (compression, layout) so a new
|
# Bump if the on-disk artifact *format* changes (compression, layout) so a new
|
||||||
# scheme can't collide with a cached/published artifact of the old one.
|
# scheme can't collide with a cached/published artifact of the old one.
|
||||||
_ARTIFACT_FORMAT = "1"
|
_ARTIFACT_FORMAT = "1"
|
||||||
@@ -164,7 +166,9 @@ def _download(url: str, dest: Path) -> None:
|
|||||||
"""Stream `url` to `dest` (atomic via a `.part` sibling)."""
|
"""Stream `url` to `dest` (atomic via a `.part` sibling)."""
|
||||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(_open(url)) as resp, open(tmp, "wb") as out:
|
with urllib.request.urlopen(
|
||||||
|
_open(url), timeout=ARTIFACT_HTTP_TIMEOUT_SECONDS,
|
||||||
|
) as resp, open(tmp, "wb") as out:
|
||||||
shutil.copyfileobj(resp, out, _CHUNK)
|
shutil.copyfileobj(resp, out, _CHUNK)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ from ...log import die, info, warn
|
|||||||
from ...supervisor.types import SUPERVISE_PORT
|
from ...supervisor.types import SUPERVISE_PORT
|
||||||
from ..docker.egress import EGRESS_PORT
|
from ..docker.egress import EGRESS_PORT
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
from . import firecracker_vm, image_builder, isolation_probe, netpool, util
|
from . import firecracker_vm, image_builder, isolation_probe, lifecycle_lock, netpool, util
|
||||||
from .bottle import FirecrackerBottle
|
from .bottle import FirecrackerBottle
|
||||||
from .bottle_plan import FirecrackerBottlePlan
|
from .bottle_plan import FirecrackerBottlePlan
|
||||||
from ...orchestrator.store.config_store import resolve_teardown_timeout
|
from ...orchestrator.store.config_store import resolve_teardown_timeout
|
||||||
@@ -164,25 +164,29 @@ def launch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 6: build the per-bottle rootfs + SSH key, then boot.
|
# Step 6: build the per-bottle rootfs + SSH key, then boot.
|
||||||
run_dir = util.cache_dir() / "run" / plan.slug
|
# Cleanup takes the same lock while refreshing its process snapshot.
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
# Hold it until the VMM exists so a newly-created run dir can never be
|
||||||
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
|
# mistaken for an orphan in the build-before-boot window.
|
||||||
# doesn't leak. Registered before vm.terminate below so it runs *after*
|
with lifecycle_lock.hold():
|
||||||
# it (ExitStack is LIFO): the VM is gone before we rm its rootfs.
|
run_dir = util.cache_dir() / "run" / plan.slug
|
||||||
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
rootfs = run_dir / "rootfs.ext4"
|
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
|
||||||
util.build_rootfs_ext4(agent_base, rootfs)
|
# doesn't leak. Registered before vm.terminate below so it runs
|
||||||
private_key, pubkey = util.generate_keypair(run_dir)
|
# *after* it (ExitStack is LIFO).
|
||||||
|
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
|
||||||
|
rootfs = run_dir / "rootfs.ext4"
|
||||||
|
util.build_rootfs_ext4(agent_base, rootfs)
|
||||||
|
private_key, pubkey = util.generate_keypair(run_dir)
|
||||||
|
|
||||||
vm = firecracker_vm.boot(
|
vm = firecracker_vm.boot(
|
||||||
name=plan.container_name,
|
name=plan.container_name,
|
||||||
rootfs=rootfs,
|
rootfs=rootfs,
|
||||||
tap=slot.iface,
|
tap=slot.iface,
|
||||||
guest_ip=slot.guest_ip,
|
guest_ip=slot.guest_ip,
|
||||||
host_ip=slot.host_ip,
|
host_ip=slot.host_ip,
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
run_dir=run_dir,
|
run_dir=run_dir,
|
||||||
)
|
)
|
||||||
stack.callback(vm.terminate)
|
stack.callback(vm.terminate)
|
||||||
firecracker_vm.wait_for_ssh(vm, private_key)
|
firecracker_vm.wait_for_ssh(vm, private_key)
|
||||||
persist_env_var_secret(private_key, slot.guest_ip, ctx.env_var_secret)
|
persist_env_var_secret(private_key, slot.guest_ip, ctx.env_var_secret)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Serialize Firecracker run-directory creation with orphan cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_path() -> Path:
|
||||||
|
return util.cache_dir() / "run.lifecycle.lock"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def hold() -> Generator[None]:
|
||||||
|
"""Exclude cleanup while a launch directory lacks a visible VMM."""
|
||||||
|
path = _lock_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["hold"]
|
||||||
@@ -34,6 +34,7 @@ from pathlib import Path
|
|||||||
from . import infra_artifact, infra_vm, util
|
from . import infra_artifact, infra_vm, util
|
||||||
|
|
||||||
_CHUNK = 1 << 20
|
_CHUNK = 1 << 20
|
||||||
|
_REGISTRY_HTTP_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
_GZ_NAME = "rootfs.ext4.gz"
|
_GZ_NAME = "rootfs.ext4.gz"
|
||||||
_SHA_NAME = "rootfs.ext4.gz.sha256"
|
_SHA_NAME = "rootfs.ext4.gz.sha256"
|
||||||
@@ -91,7 +92,9 @@ def _put(url: str, body: "bytes | Path", token: str) -> None:
|
|||||||
req.add_header("Authorization", f"token {token}")
|
req.add_header("Authorization", f"token {token}")
|
||||||
req.add_header("Content-Type", "application/octet-stream")
|
req.add_header("Content-Type", "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(
|
||||||
|
req, timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||||
|
) as resp:
|
||||||
print(f" uploaded {url} (HTTP {resp.status})")
|
print(f" uploaded {url} (HTTP {resp.status})")
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code == 409:
|
if e.code == 409:
|
||||||
@@ -112,7 +115,9 @@ def _delete(url: str, token: str) -> None:
|
|||||||
if token:
|
if token:
|
||||||
req.add_header("Authorization", f"token {token}")
|
req.add_header("Authorization", f"token {token}")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req):
|
with urllib.request.urlopen(
|
||||||
|
req, timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||||
|
):
|
||||||
pass
|
pass
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code != 404:
|
if e.code != 404:
|
||||||
@@ -151,7 +156,10 @@ def _try_download_published(role: str, role_dir: Path) -> str | None:
|
|||||||
version = _role_version(role)
|
version = _role_version(role)
|
||||||
sha_url = infra_artifact.artifact_url(version, _SHA_NAME, role=role)
|
sha_url = infra_artifact.artifact_url(version, _SHA_NAME, role=role)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(infra_artifact._open(sha_url)):
|
with urllib.request.urlopen(
|
||||||
|
infra_artifact._open(sha_url),
|
||||||
|
timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||||
|
):
|
||||||
pass
|
pass
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code == 404:
|
if e.code == 404:
|
||||||
@@ -195,7 +203,10 @@ def _publish_bundle(role: str, role_dir: Path, token: str) -> str:
|
|||||||
# present, a re-publish is a no-op. Otherwise clear any partial upload left
|
# present, a re-publish is a no-op. Otherwise clear any partial upload left
|
||||||
# by an interrupted prior attempt and upload the complete set.
|
# by an interrupted prior attempt and upload the complete set.
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(infra_artifact._open(sha_url)) as resp:
|
with urllib.request.urlopen(
|
||||||
|
infra_artifact._open(sha_url),
|
||||||
|
timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||||
|
) as resp:
|
||||||
remote_sha = resp.read().decode("utf-8").split()[0].strip().lower()
|
remote_sha = resp.read().decode("utf-8").split()[0].strip().lower()
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code != 404:
|
if e.code != 404:
|
||||||
|
|||||||
@@ -25,3 +25,11 @@ class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
|||||||
@property
|
@property
|
||||||
def empty(self) -> bool:
|
def empty(self) -> bool:
|
||||||
return not self.containers and not self.networks
|
return not self.containers and not self.networks
|
||||||
|
|
||||||
|
def intersect(self, current: BottleCleanupPlan) -> "MacosContainerBottleCleanupPlan":
|
||||||
|
if not isinstance(current, MacosContainerBottleCleanupPlan):
|
||||||
|
raise TypeError("cleanup plans must have the same backend type")
|
||||||
|
return MacosContainerBottleCleanupPlan(
|
||||||
|
containers=tuple(x for x in self.containers if x in current.containers),
|
||||||
|
networks=tuple(x for x in self.networks if x in current.networks),
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from ...log import info, warn
|
from .. import EnumerationError
|
||||||
|
from ..cleanup_control import CleanupFailures
|
||||||
|
from ...log import info
|
||||||
from . import util as container_mod
|
from . import util as container_mod
|
||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||||
|
|
||||||
@@ -19,8 +21,8 @@ def _list_prefixed_containers() -> list[str]:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
warn(f"container list failed: {result.stderr.strip()}")
|
detail = result.stderr.strip() or f"exit {result.returncode}"
|
||||||
return []
|
raise EnumerationError(f"container list failed: {detail}")
|
||||||
return sorted(
|
return sorted(
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||||
if name.startswith(_PREFIX)
|
if name.startswith(_PREFIX)
|
||||||
@@ -35,7 +37,8 @@ def _list_prefixed_networks() -> list[str]:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return []
|
detail = result.stderr.strip() or f"exit {result.returncode}"
|
||||||
|
raise EnumerationError(f"container network list failed: {detail}")
|
||||||
return sorted(
|
return sorted(
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||||
if name.startswith(_PREFIX)
|
if name.startswith(_PREFIX)
|
||||||
@@ -51,19 +54,17 @@ def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
|||||||
|
|
||||||
|
|
||||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
||||||
|
failures = CleanupFailures()
|
||||||
for name in plan.containers:
|
for name in plan.containers:
|
||||||
info(f"container delete --force {name}")
|
info(f"container delete --force {name}")
|
||||||
subprocess.run(
|
failures.run(
|
||||||
["container", "delete", "--force", name],
|
["container", "delete", "--force", name],
|
||||||
stdout=subprocess.DEVNULL,
|
f"deleting container {name}",
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
for name in plan.networks:
|
for name in plan.networks:
|
||||||
info(f"container network delete {name}")
|
info(f"container network delete {name}")
|
||||||
subprocess.run(
|
failures.run(
|
||||||
["container", "network", "delete", name],
|
["container", "network", "delete", name],
|
||||||
stdout=subprocess.DEVNULL,
|
f"deleting network {name}",
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
|
failures.raise_if_any()
|
||||||
|
|||||||
@@ -63,12 +63,23 @@ def provision_git_gate(
|
|||||||
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
||||||
creds = _creds_dir(bottle_id)
|
creds = _creds_dir(bottle_id)
|
||||||
transport.exec(["mkdir", "-p", creds])
|
transport.exec(["mkdir", "-p", creds])
|
||||||
|
transport.exec(["chmod", "700", creds])
|
||||||
|
credential_paths: list[str] = []
|
||||||
for u in plan.upstreams:
|
for u in plan.upstreams:
|
||||||
if u.identity_file:
|
if u.identity_file:
|
||||||
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
|
key_path = f"{creds}/{u.name}-key"
|
||||||
|
transport.cp_into(u.identity_file, key_path)
|
||||||
|
credential_paths.append(key_path)
|
||||||
known_hosts = str(u.known_hosts_file)
|
known_hosts = str(u.known_hosts_file)
|
||||||
if known_hosts and known_hosts != ".":
|
if known_hosts and known_hosts != ".":
|
||||||
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
|
known_hosts_path = f"{creds}/{u.name}-known_hosts"
|
||||||
|
transport.cp_into(known_hosts, known_hosts_path)
|
||||||
|
credential_paths.append(known_hosts_path)
|
||||||
|
# Copy-mode behavior differs across Docker, Apple Container, and SSH.
|
||||||
|
# Apply the security contract inside the gateway so every backend produces
|
||||||
|
# the same private credential namespace.
|
||||||
|
if credential_paths:
|
||||||
|
transport.exec(["chmod", "600", *credential_paths])
|
||||||
# Init the bare repos + per-repo credential config for this namespace.
|
# Init the bare repos + per-repo credential config for this namespace.
|
||||||
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
||||||
transport.exec(["sh", "-c", script])
|
transport.exec(["sh", "-c", script])
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from ...backend import get_bottle_backend, has_backend, known_backend_names
|
from ...backend import get_bottle_backend, has_backend, known_backend_names
|
||||||
|
from ...backend.cleanup_control import CleanupError
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...util import read_tty_line
|
from ...util import read_tty_line
|
||||||
|
|
||||||
@@ -52,10 +53,20 @@ def cmd_cleanup(_argv: list[str]) -> int:
|
|||||||
info("cleanup: skipped")
|
info("cleanup: skipped")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
for name, backend, plan in prepared:
|
# Confirmation authorizes a fresh authoritative snapshot, not blind use of
|
||||||
if plan.empty:
|
# identities that may have changed while the operator reviewed the preview.
|
||||||
|
failures: list[str] = []
|
||||||
|
for name, backend, displayed in prepared:
|
||||||
|
current = backend.prepare_cleanup()
|
||||||
|
approved = displayed.intersect(current)
|
||||||
|
if approved.empty:
|
||||||
continue
|
continue
|
||||||
backend.cleanup(plan)
|
try:
|
||||||
|
backend.cleanup(approved)
|
||||||
|
except CleanupError as exc:
|
||||||
|
failures.append(f"{name}: {exc}")
|
||||||
|
if failures:
|
||||||
|
raise CleanupError("cleanup incomplete: " + "; ".join(failures))
|
||||||
info("cleanup: done")
|
info("cleanup: done")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -136,10 +136,18 @@ def _pump(name: str, stream: IO[bytes]) -> None:
|
|||||||
"""Read lines from `stream`, prefix with `[name]`, write to
|
"""Read lines from `stream`, prefix with `[name]`, write to
|
||||||
stdout. Runs in its own thread per child; daemon=True so a
|
stdout. Runs in its own thread per child; daemon=True so a
|
||||||
blocked read doesn't keep the process alive after main exits."""
|
blocked read doesn't keep the process alive after main exits."""
|
||||||
for raw in iter(stream.readline, b""):
|
try:
|
||||||
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
for raw in iter(stream.readline, b""):
|
||||||
sys.stdout.write(f"[{name}] {line}\n")
|
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.write(f"[{name}] {line}\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
# The manager closes a dead child's pipe after wait() and before a
|
||||||
|
# restart. A pump can be between readline calls at that exact moment;
|
||||||
|
# closed-stream errors are normal completion, not uncaught thread
|
||||||
|
# failures. Preserve genuinely unexpected I/O diagnostics.
|
||||||
|
if not stream.closed:
|
||||||
|
_log(f"{name} output pump stopped: {type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
|
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Shared resource boundaries for gateway stdlib HTTP services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import io
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Readable(Protocol):
|
||||||
|
def read(self, size: int = -1, /) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Writable(Protocol):
|
||||||
|
def write(self, data: bytes, /) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BodyReadError(Exception):
|
||||||
|
status: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def read_declared_body(
|
||||||
|
stream: Readable,
|
||||||
|
connection: socket.socket,
|
||||||
|
raw_length: str | None,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
timeout_seconds: float,
|
||||||
|
require_length: bool,
|
||||||
|
) -> bytes:
|
||||||
|
"""Validate and read exactly one declared body under a read deadline."""
|
||||||
|
output = io.BytesIO()
|
||||||
|
copy_declared_body(
|
||||||
|
stream, output, connection, raw_length, maximum=maximum,
|
||||||
|
timeout_seconds=timeout_seconds, require_length=require_length,
|
||||||
|
)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_declared_body(
|
||||||
|
stream: Readable,
|
||||||
|
output: Writable,
|
||||||
|
connection: socket.socket,
|
||||||
|
raw_length: str | None,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
timeout_seconds: float,
|
||||||
|
require_length: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Copy one declared body to a sink without retaining it in memory."""
|
||||||
|
if raw_length is None:
|
||||||
|
if require_length:
|
||||||
|
raise BodyReadError(411, "Content-Length required")
|
||||||
|
raw_length = "0"
|
||||||
|
try:
|
||||||
|
length = int(raw_length)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BodyReadError(400, "invalid Content-Length") from exc
|
||||||
|
if length < 0:
|
||||||
|
raise BodyReadError(400, "invalid Content-Length")
|
||||||
|
if length > maximum:
|
||||||
|
raise BodyReadError(413, "request body too large")
|
||||||
|
previous_timeout = connection.gettimeout()
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
remaining = length
|
||||||
|
try:
|
||||||
|
while remaining:
|
||||||
|
timeout = deadline - time.monotonic()
|
||||||
|
if timeout <= 0:
|
||||||
|
raise BodyReadError(408, "request body read timed out")
|
||||||
|
connection.settimeout(timeout)
|
||||||
|
chunk = stream.read(min(remaining, 64 * 1024))
|
||||||
|
if not chunk:
|
||||||
|
raise BodyReadError(400, "incomplete request body")
|
||||||
|
output.write(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise BodyReadError(408, "request body read timed out") from exc
|
||||||
|
finally:
|
||||||
|
connection.settimeout(previous_timeout)
|
||||||
|
return length
|
||||||
|
|
||||||
|
|
||||||
|
class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
|
||||||
|
"""ThreadingHTTPServer with a hard cap on in-flight request threads."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__( # pylint: disable=consider-using-with
|
||||||
|
self, *args, max_workers: int = 32, **kwargs, # type: ignore[no-untyped-def]
|
||||||
|
):
|
||||||
|
if max_workers < 1:
|
||||||
|
raise ValueError("max_workers must be positive")
|
||||||
|
self._request_slots = threading.BoundedSemaphore(max_workers)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def process_request(
|
||||||
|
self, request: Any, client_address: Any,
|
||||||
|
) -> None:
|
||||||
|
if not self._request_slots.acquire( # pylint: disable=consider-using-with
|
||||||
|
blocking=False,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
request.sendall(
|
||||||
|
b"HTTP/1.1 503 Service Unavailable\r\n"
|
||||||
|
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.shutdown_request(request)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
super().process_request(request, client_address)
|
||||||
|
except BaseException:
|
||||||
|
self._request_slots.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def process_request_thread(
|
||||||
|
self, request: Any, client_address: Any,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
super().process_request_thread(request, client_address)
|
||||||
|
finally:
|
||||||
|
self._request_slots.release()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BodyReadError",
|
||||||
|
"BoundedThreadingHTTPServer",
|
||||||
|
"copy_declared_body",
|
||||||
|
"read_declared_body",
|
||||||
|
]
|
||||||
@@ -21,12 +21,19 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
import typing
|
import typing
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||||
|
from bot_bottle.gateway.bounded_http import (
|
||||||
|
BodyReadError,
|
||||||
|
BoundedThreadingHTTPServer,
|
||||||
|
copy_declared_body,
|
||||||
|
)
|
||||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +84,10 @@ def resolve_sandbox_root(
|
|||||||
|
|
||||||
# Bound memory use while still allowing ordinary git push packfiles.
|
# Bound memory use while still allowing ordinary git push packfiles.
|
||||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||||
|
REQUEST_BODY_TIMEOUT_SECONDS = 30.0
|
||||||
|
MAX_REQUEST_WORKERS = 16
|
||||||
|
MAX_BODY_WORKERS = 2
|
||||||
|
_BODY_WORK_SLOTS = threading.BoundedSemaphore(MAX_BODY_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
class GitHttpHandler(BaseHTTPRequestHandler):
|
class GitHttpHandler(BaseHTTPRequestHandler):
|
||||||
@@ -184,27 +195,40 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
value = self.headers.get(header)
|
value = self.headers.get(header)
|
||||||
if value:
|
if value:
|
||||||
env[variable] = value
|
env[variable] = value
|
||||||
raw_length = self.headers.get("content-length", "0") or "0"
|
if not _BODY_WORK_SLOTS.acquire(blocking=False):
|
||||||
|
self.send_error(503, "git request capacity exhausted")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
length = int(raw_length)
|
with tempfile.TemporaryFile() as body:
|
||||||
except ValueError:
|
try:
|
||||||
self.send_error(400, "Bad Content-Length")
|
copy_declared_body(
|
||||||
return
|
self.rfile,
|
||||||
if length < 0:
|
body,
|
||||||
self.send_error(400, "Negative Content-Length")
|
self.connection,
|
||||||
return
|
self.headers.get("content-length"),
|
||||||
if length > MAX_BODY_BYTES:
|
maximum=MAX_BODY_BYTES,
|
||||||
self.send_error(413, "Request body too large")
|
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
|
||||||
return
|
require_length=False,
|
||||||
body = self.rfile.read(length) if length else b""
|
)
|
||||||
proc = subprocess.run(
|
except BodyReadError as exc:
|
||||||
["git", "http-backend"],
|
self.send_error(exc.status, exc.message)
|
||||||
input=body,
|
return
|
||||||
env=env,
|
body.seek(0)
|
||||||
capture_output=True,
|
try:
|
||||||
check=False,
|
proc = subprocess.run(
|
||||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
["git", "http-backend"],
|
||||||
)
|
stdin=body,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
self.log_message("git http-backend unavailable: %s", exc)
|
||||||
|
self.send_error(503, "git backend unavailable")
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
_BODY_WORK_SLOTS.release()
|
||||||
self._write_cgi_response(proc.stdout)
|
self._write_cgi_response(proc.stdout)
|
||||||
|
|
||||||
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
|
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
|
||||||
@@ -273,7 +297,9 @@ def main() -> int:
|
|||||||
"(no single-tenant flat-root fallback)\n"
|
"(no single-tenant flat-root fallback)\n"
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
server = BoundedThreadingHTTPServer(
|
||||||
|
("0.0.0.0", port), GitHttpHandler, max_workers=MAX_REQUEST_WORKERS,
|
||||||
|
)
|
||||||
# Resolve each request's sandbox namespace by source IP against the
|
# Resolve each request's sandbox namespace by source IP against the
|
||||||
# orchestrator control plane.
|
# orchestrator control plane.
|
||||||
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
|
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import json
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Protocol
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
from bot_bottle.gateway.egress.context import resolve_client_context
|
from bot_bottle.gateway.egress.schema import load_config, route_to_yaml_dict
|
||||||
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
|
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
from bot_bottle.gateway.policy_resolver import PolicyResolver
|
|
||||||
from bot_bottle.supervisor import types as _sv
|
from bot_bottle.supervisor import types as _sv
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +23,10 @@ class MethodNotFoundError(Exception):
|
|||||||
"""Raised when a JSON-RPC method has no MCP handler."""
|
"""Raised when a JSON-RPC method has no MCP handler."""
|
||||||
|
|
||||||
|
|
||||||
|
class RouteResolutionError(Exception):
|
||||||
|
"""The caller's live route table could not be resolved authoritatively."""
|
||||||
|
|
||||||
|
|
||||||
Handler = Callable[[dict[str, object]], object]
|
Handler = Callable[[dict[str, object]], object]
|
||||||
|
|
||||||
|
|
||||||
@@ -60,10 +63,19 @@ def resolved_routes_payload(
|
|||||||
source_ip: str,
|
source_ip: str,
|
||||||
identity_token: str,
|
identity_token: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Render the calling bottle's routes, failing closed to an empty list."""
|
"""Render an authoritatively resolved route table for the calling bottle."""
|
||||||
config, _slug, _tokens = resolve_client_context(
|
try:
|
||||||
resolver, source_ip, identity_token,
|
policy, bottle_id, _tokens = resolver.resolve_policy_and_bottle_id(
|
||||||
)
|
source_ip, identity_token,
|
||||||
|
)
|
||||||
|
except PolicyResolveError as exc:
|
||||||
|
raise RouteResolutionError("orchestrator unavailable") from exc
|
||||||
|
if not bottle_id:
|
||||||
|
raise RouteResolutionError("request source is not attributed to a bottle")
|
||||||
|
try:
|
||||||
|
config = load_config(policy or "")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RouteResolutionError("resolved policy is invalid") from exc
|
||||||
body = json.dumps(
|
body = json.dumps(
|
||||||
{"routes": [route_to_yaml_dict(route) for route in config.routes]},
|
{"routes": [route_to_yaml_dict(route) for route in config.routes]},
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -74,6 +86,7 @@ def resolved_routes_payload(
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Handlers",
|
"Handlers",
|
||||||
"MethodNotFoundError",
|
"MethodNotFoundError",
|
||||||
|
"RouteResolutionError",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"resolved_routes_payload",
|
"resolved_routes_payload",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -51,19 +51,24 @@ from __future__ import annotations
|
|||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socketserver
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from bot_bottle.constants import IDENTITY_HEADER
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
|
from bot_bottle.gateway.bounded_http import (
|
||||||
|
BodyReadError,
|
||||||
|
BoundedThreadingHTTPServer,
|
||||||
|
read_declared_body,
|
||||||
|
)
|
||||||
from bot_bottle.gateway.egress.schema import load_config
|
from bot_bottle.gateway.egress.schema import load_config
|
||||||
from bot_bottle.gateway.egress.types import LOG_OFF
|
from bot_bottle.gateway.egress.types import LOG_OFF
|
||||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
from bot_bottle.gateway.supervisor.mcp_dispatch import (
|
from bot_bottle.gateway.supervisor.mcp_dispatch import (
|
||||||
Handlers as DispatchHandlers,
|
Handlers as DispatchHandlers,
|
||||||
MethodNotFoundError,
|
MethodNotFoundError,
|
||||||
|
RouteResolutionError,
|
||||||
dispatch,
|
dispatch,
|
||||||
resolved_routes_payload,
|
resolved_routes_payload,
|
||||||
)
|
)
|
||||||
@@ -570,6 +575,8 @@ def format_unknown_proposal_text(proposal_id: str) -> str:
|
|||||||
# Max request body the server accepts. 1 MB is well above any realistic
|
# Max request body the server accepts. 1 MB is well above any realistic
|
||||||
# routes.yaml proposal.
|
# routes.yaml proposal.
|
||||||
MAX_BODY_BYTES = 1 * 1024 * 1024
|
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||||
|
REQUEST_BODY_TIMEOUT_SECONDS = 10.0
|
||||||
|
MAX_REQUEST_WORKERS = 32
|
||||||
|
|
||||||
|
|
||||||
class MCPHandler(http.server.BaseHTTPRequestHandler):
|
class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||||
@@ -592,19 +599,18 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self._write_text(405, "use POST for MCP requests\n")
|
self._write_text(405, "use POST for MCP requests\n")
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
length_header = self.headers.get("Content-Length")
|
|
||||||
if length_header is None:
|
|
||||||
self._write_text(411, "Content-Length required\n")
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
length = int(length_header)
|
body = read_declared_body(
|
||||||
except ValueError:
|
self.rfile,
|
||||||
self._write_text(400, "invalid Content-Length\n")
|
self.connection,
|
||||||
|
self.headers.get("Content-Length"),
|
||||||
|
maximum=MAX_BODY_BYTES,
|
||||||
|
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
|
||||||
|
require_length=True,
|
||||||
|
)
|
||||||
|
except BodyReadError as exc:
|
||||||
|
self._write_text(exc.status, exc.message + "\n")
|
||||||
return
|
return
|
||||||
if length < 0 or length > MAX_BODY_BYTES:
|
|
||||||
self._write_text(413, "request body too large\n")
|
|
||||||
return
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = parse_jsonrpc(body)
|
req = parse_jsonrpc(body)
|
||||||
@@ -660,20 +666,25 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
identity_token=self._identity_token(),
|
identity_token=self._identity_token(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return dispatch(
|
def list_routes(_params: dict[str, object]) -> object:
|
||||||
req,
|
try:
|
||||||
DispatchHandlers(
|
return resolved_routes_payload(
|
||||||
initialize=handle_initialize,
|
|
||||||
tools_list=handle_tools_list,
|
|
||||||
list_routes=lambda _params: resolved_routes_payload(
|
|
||||||
self._resolver_or_fail(),
|
self._resolver_or_fail(),
|
||||||
self.client_address[0],
|
self.client_address[0],
|
||||||
self._identity_token(),
|
self._identity_token(),
|
||||||
),
|
)
|
||||||
check_proposal=check,
|
except RouteResolutionError as exc:
|
||||||
propose=propose,
|
raise _RpcInternalError(
|
||||||
),
|
f"could not resolve live egress routes: {exc}"
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
|
return dispatch(req, DispatchHandlers(
|
||||||
|
initialize=handle_initialize,
|
||||||
|
tools_list=handle_tools_list,
|
||||||
|
list_routes=list_routes,
|
||||||
|
check_proposal=check,
|
||||||
|
propose=propose,
|
||||||
|
))
|
||||||
|
|
||||||
def _identity_token(self) -> str:
|
def _identity_token(self) -> str:
|
||||||
"""The agent's per-bottle identity token from the request header (the
|
"""The agent's per-bottle identity token from the request header (the
|
||||||
@@ -711,7 +722,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(encoded)
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
|
||||||
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
class MCPServer(BoundedThreadingHTTPServer):
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
config: ServerConfig = ServerConfig()
|
config: ServerConfig = ServerConfig()
|
||||||
@@ -720,6 +731,9 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
# closed per request (see `_resolver_or_fail`).
|
# closed per request (see `_resolver_or_fail`).
|
||||||
policy_resolver: "PolicyResolver | None" = None
|
policy_resolver: "PolicyResolver | None" = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
super().__init__(*args, max_workers=MAX_REQUEST_WORKERS, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# --- Entry point -----------------------------------------------------------
|
# --- Entry point -----------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import asyncio
|
|||||||
import math
|
import math
|
||||||
import sys
|
import sys
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
@@ -208,6 +209,19 @@ def create_app(orch: OrchestratorCore, *, signing_key: str) -> FastAPI:
|
|||||||
)
|
)
|
||||||
app.add_middleware(ControlPlaneBoundary, signing_key=key)
|
app.add_middleware(ControlPlaneBoundary, signing_key=key)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def invalid_request(
|
||||||
|
_request: object, exc: RequestValidationError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
errors = exc.errors()
|
||||||
|
location = errors[0].get("loc", ()) if errors else ()
|
||||||
|
field = str(location[1]) if len(location) > 1 else ""
|
||||||
|
suffix = f": {field}" if field else ""
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": f"invalid request body{suffix}"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ 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, encrypt_value, is_legacy_blob
|
from .store.secret_store import decrypt_value
|
||||||
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
|
||||||
@@ -377,12 +377,6 @@ class OrchestratorCore:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
self._tokens[bottle_id] = decrypted
|
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 ----------------------------------------------
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ _MIGRATIONS = TableMigrations(
|
|||||||
# v5 — index for fast per-bottle lookups and bulk DELETE on teardown.
|
# v5 — index for fast per-bottle lookups and bulk DELETE on teardown.
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bottled_agent_secrets_id "
|
"CREATE INDEX IF NOT EXISTS idx_bottled_agent_secrets_id "
|
||||||
"ON bottled_agent_secrets (bottled_agent_id, type)",
|
"ON bottled_agent_secrets (bottled_agent_id, type)",
|
||||||
|
# v6 — unauthenticated legacy ciphertext must never be selected by
|
||||||
|
# attacker-controlled blob contents. Existing local agents are
|
||||||
|
# intentionally reprovisioned instead of retaining downgrade support.
|
||||||
|
"DELETE FROM bottled_agent_secrets",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ value is encrypted independently. New output blobs are:
|
|||||||
|
|
||||||
``version || nonce (16 bytes) || ciphertext || tag (32 bytes)``
|
``version || nonce (16 bytes) || ciphertext || tag (32 bytes)``
|
||||||
|
|
||||||
encoded as URL-safe base64 (no padding). The version marker lets the reader
|
encoded as URL-safe base64 (no padding). Unversioned legacy ciphertext is
|
||||||
accept legacy ``nonce || ciphertext`` rows long enough to rewrite them in the
|
rejected; the registry migration clears those rows rather than allowing blob
|
||||||
authenticated format after a successful reprovision.
|
contents to select an unauthenticated decoder.
|
||||||
|
|
||||||
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)]
|
||||||
@@ -84,44 +84,18 @@ def encrypt_value(secret_b64: str, plaintext: str) -> str:
|
|||||||
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
|
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
def is_legacy_blob(blob_b64: str) -> bool:
|
|
||||||
"""Whether *blob_b64* uses the pre-authentication storage format."""
|
|
||||||
try:
|
|
||||||
return not _b64dec(blob_b64).startswith(_VERSION)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_legacy(key: bytes, blob: bytes) -> str:
|
|
||||||
"""Read the original ``nonce || ciphertext`` format for migration only."""
|
|
||||||
if len(blob) < _NONCE_BYTES:
|
|
||||||
raise ValueError("ciphertext blob too short")
|
|
||||||
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
|
|
||||||
pt = bytearray()
|
|
||||||
# The legacy format used the byte offset as the PRF counter.
|
|
||||||
for i in range(0, len(ciphertext), _BLOCK):
|
|
||||||
chunk = ciphertext[i : i + _BLOCK]
|
|
||||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
|
||||||
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
|
||||||
try:
|
|
||||||
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:
|
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
||||||
"""Decrypt a blob produced by :func:`encrypt_value`.
|
"""Decrypt a blob produced by :func:`encrypt_value`.
|
||||||
|
|
||||||
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
||||||
input, authentication failure, or a key mismatch. Legacy unauthenticated
|
input, authentication failure, or a key mismatch."""
|
||||||
rows remain readable so callers can migrate them immediately."""
|
|
||||||
key = _b64dec(secret_b64)
|
key = _b64dec(secret_b64)
|
||||||
try:
|
try:
|
||||||
blob = _b64dec(blob_b64)
|
blob = _b64dec(blob_b64)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
||||||
if not blob.startswith(_VERSION):
|
if not blob.startswith(_VERSION):
|
||||||
return _decrypt_legacy(key, blob)
|
raise ValueError("unsupported ciphertext format")
|
||||||
minimum = len(_VERSION) + _NONCE_BYTES + _TAG_BYTES
|
minimum = len(_VERSION) + _NONCE_BYTES + _TAG_BYTES
|
||||||
if len(blob) < minimum:
|
if len(blob) < minimum:
|
||||||
raise ValueError("ciphertext blob too short")
|
raise ValueError("ciphertext blob too short")
|
||||||
@@ -152,5 +126,4 @@ __all__ = [
|
|||||||
"new_env_var_secret",
|
"new_env_var_secret",
|
||||||
"encrypt_value",
|
"encrypt_value",
|
||||||
"decrypt_value",
|
"decrypt_value",
|
||||||
"is_legacy_blob",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import stat
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,9 +21,62 @@ class DbStore:
|
|||||||
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
|
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._migrations = migrations
|
self._migrations = migrations
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
self._secure_parent()
|
||||||
|
if self.db_path.exists():
|
||||||
|
self._chmod()
|
||||||
|
|
||||||
|
def _secure_parent(self) -> None:
|
||||||
|
"""Create and verify the private parent directory."""
|
||||||
|
parent = self.db_path.parent
|
||||||
|
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if parent.is_symlink():
|
||||||
|
raise PermissionError(f"database directory must not be a symlink: {parent}")
|
||||||
|
parent.chmod(0o700)
|
||||||
|
parent_stat = parent.lstat()
|
||||||
|
if not stat.S_ISDIR(parent_stat.st_mode):
|
||||||
|
raise PermissionError(f"database parent is not a directory: {parent}")
|
||||||
|
if stat.S_IMODE(parent_stat.st_mode) != 0o700:
|
||||||
|
raise PermissionError(f"database directory is not mode 0700: {parent}")
|
||||||
|
|
||||||
|
def _secure_db_file(self) -> None:
|
||||||
|
"""Create the database without a permissive filesystem window.
|
||||||
|
|
||||||
|
SQLite otherwise creates a missing database using the process umask.
|
||||||
|
This store contains control-plane identity tokens, so both creation and
|
||||||
|
repair are fail-closed rather than best-effort.
|
||||||
|
"""
|
||||||
|
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
|
||||||
|
fd = os.open(
|
||||||
|
self.db_path,
|
||||||
|
flags,
|
||||||
|
stat.S_IRUSR | stat.S_IWUSR,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._secure_open_file(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
def _chmod(self) -> None:
|
||||||
|
"""Enforce and verify the private database mode after every write."""
|
||||||
|
fd = os.open(self.db_path, os.O_RDWR | os.O_NOFOLLOW)
|
||||||
|
try:
|
||||||
|
self._secure_open_file(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
def _secure_open_file(self, fd: int) -> None:
|
||||||
|
"""Pin, validate, and secure an opened database filesystem object."""
|
||||||
|
file_stat = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(file_stat.st_mode):
|
||||||
|
raise PermissionError(
|
||||||
|
f"database must be a regular file: {self.db_path}"
|
||||||
|
)
|
||||||
|
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600:
|
||||||
|
raise PermissionError(f"database is not mode 0600: {self.db_path}")
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
self._secure_db_file()
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
@@ -51,16 +106,9 @@ class DbStore:
|
|||||||
return version == len(self._migrations.migrations)
|
return version == len(self._migrations.migrations)
|
||||||
|
|
||||||
def migrate(self) -> None:
|
def migrate(self) -> None:
|
||||||
"""Apply any pending migrations and set permissions on the DB file."""
|
"""Apply any pending migrations to the already-secured DB file."""
|
||||||
with self._connection() as conn:
|
with self._connection() as conn:
|
||||||
self._migrations.apply(conn)
|
self._migrations.apply(conn)
|
||||||
self._chmod()
|
|
||||||
|
|
||||||
def _chmod(self) -> None:
|
|
||||||
try:
|
|
||||||
self.db_path.chmod(0o600)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["DbStore", "DbVersionError"]
|
__all__ = ["DbStore", "DbVersionError"]
|
||||||
|
|||||||
@@ -32,9 +32,17 @@ 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 isolation/sandbox tools.
|
The main table compares bot-bottle against fifteen canonical
|
||||||
|
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
|
||||||
@@ -542,6 +550,199 @@ 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.*
|
||||||
@@ -616,6 +817,70 @@ 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
|
||||||
@@ -642,6 +907,19 @@ capability passing rather than kernel boundaries.
|
|||||||
|
|
||||||
### 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.
|
||||||
@@ -652,10 +930,11 @@ capability passing rather than kernel boundaries.
|
|||||||
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: the SDK-first programmatic API style of boxlite /
|
Not worth borrowing: SDK-first *policy configuration* as used by boxlite /
|
||||||
microsandbox (cuts against the declarative-manifest stance), and the
|
microsandbox (cuts against the reviewable declarative-manifest stance), and
|
||||||
hosted-SaaS dashboard model of tilde.run (cuts against the
|
the hosted-SaaS custody model of tilde.run (cuts against the "infrastructure I
|
||||||
"infrastructure I control" goal).
|
control" goal). A provider-neutral runtime-control API is a separate concern
|
||||||
|
and is worth borrowing.
|
||||||
|
|
||||||
## Publishing and positioning verdict
|
## Publishing and positioning verdict
|
||||||
|
|
||||||
@@ -679,9 +958,15 @@ 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 a local wrapper such as claudebox or
|
plumbing. The main competitive risks are now:
|
||||||
Docker sbx growing a role-manifest layer, and GUI products such as SuperHQ
|
|
||||||
adding equivalent policy and audit depth.
|
- a local wrapper such as yolo-cage, claudebox, or Docker sbx growing a
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
# 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.
|
||||||
@@ -17,6 +17,7 @@ from bot_bottle.cli.commands import cleanup as cmd
|
|||||||
def _make_backend(empty: bool = True):
|
def _make_backend(empty: bool = True):
|
||||||
backend = MagicMock()
|
backend = MagicMock()
|
||||||
plan = MagicMock(empty=empty)
|
plan = MagicMock(empty=empty)
|
||||||
|
plan.intersect.return_value = plan
|
||||||
backend.prepare_cleanup.return_value = plan
|
backend.prepare_cleanup.return_value = plan
|
||||||
backend.cleanup = MagicMock()
|
backend.cleanup = MagicMock()
|
||||||
return backend, plan
|
return backend, plan
|
||||||
@@ -41,8 +42,8 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||||
|
|
||||||
docker.prepare_cleanup.assert_called_once()
|
self.assertEqual(2, docker.prepare_cleanup.call_count)
|
||||||
fc.prepare_cleanup.assert_called_once()
|
self.assertEqual(2, fc.prepare_cleanup.call_count)
|
||||||
docker.cleanup.assert_called_once_with(docker_plan)
|
docker.cleanup.assert_called_once_with(docker_plan)
|
||||||
fc.cleanup.assert_called_once_with(fc_plan)
|
fc.cleanup.assert_called_once_with(fc_plan)
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertEqual(0, cmd.cmd_cleanup([]))
|
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||||
|
|
||||||
docker.prepare_cleanup.assert_called_once()
|
self.assertEqual(2, docker.prepare_cleanup.call_count)
|
||||||
docker.cleanup.assert_called_once_with(docker_plan)
|
docker.cleanup.assert_called_once_with(docker_plan)
|
||||||
macos.prepare_cleanup.assert_not_called()
|
macos.prepare_cleanup.assert_not_called()
|
||||||
|
|
||||||
@@ -135,6 +136,28 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
docker.cleanup.assert_called_once_with(docker_plan)
|
docker.cleanup.assert_called_once_with(docker_plan)
|
||||||
fc.cleanup.assert_not_called()
|
fc.cleanup.assert_not_called()
|
||||||
|
|
||||||
|
def test_executes_only_displayed_resources_still_current(self):
|
||||||
|
backend = MagicMock()
|
||||||
|
preview = MagicMock(empty=False)
|
||||||
|
refreshed = MagicMock(empty=False)
|
||||||
|
approved = MagicMock(empty=False)
|
||||||
|
preview.intersect.return_value = approved
|
||||||
|
backend.prepare_cleanup.side_effect = [preview, refreshed]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
cmd, "known_backend_names", return_value=("firecracker",),
|
||||||
|
), patch.object(
|
||||||
|
cmd, "get_bottle_backend", return_value=backend,
|
||||||
|
), patch.object(
|
||||||
|
cmd, "has_backend", return_value=True,
|
||||||
|
), patch.object(
|
||||||
|
cmd, "_prompt_yes", return_value=True,
|
||||||
|
):
|
||||||
|
self.assertEqual(0, cmd.cmd_cleanup([]))
|
||||||
|
|
||||||
|
preview.intersect.assert_called_once_with(refreshed)
|
||||||
|
backend.cleanup.assert_called_once_with(approved)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.store.db_store import DbStore
|
from bot_bottle.store.db_store import DbStore
|
||||||
from bot_bottle.store.migrations import TableMigrations
|
from bot_bottle.store.migrations import TableMigrations
|
||||||
@@ -22,6 +24,48 @@ class TestDbStoreIsMigrated(unittest.TestCase):
|
|||||||
store = _store(Path(d))
|
store = _store(Path(d))
|
||||||
self.assertFalse(store.is_migrated())
|
self.assertFalse(store.is_migrated())
|
||||||
|
|
||||||
|
def test_creates_private_directory_and_database_before_first_open(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
parent = Path(d) / "store"
|
||||||
|
store = _store(parent)
|
||||||
|
self.assertEqual(0o700, stat.S_IMODE(parent.stat().st_mode))
|
||||||
|
self.assertFalse(store.db_path.exists())
|
||||||
|
store.migrate()
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(store.db_path.stat().st_mode))
|
||||||
|
|
||||||
|
def test_repairs_existing_permissions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
parent = Path(d) / "store"
|
||||||
|
parent.mkdir(mode=0o755)
|
||||||
|
db_path = parent / "test.db"
|
||||||
|
db_path.touch(mode=0o644)
|
||||||
|
store = _store(parent)
|
||||||
|
self.assertEqual(db_path, store.db_path)
|
||||||
|
self.assertEqual(0o700, stat.S_IMODE(parent.stat().st_mode))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(db_path.stat().st_mode))
|
||||||
|
|
||||||
|
def test_permission_repair_failure_is_not_suppressed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
parent = Path(d) / "store"
|
||||||
|
parent.mkdir()
|
||||||
|
(parent / "test.db").touch()
|
||||||
|
with patch("os.fchmod", side_effect=OSError("denied")):
|
||||||
|
with self.assertRaisesRegex(OSError, "denied"):
|
||||||
|
_store(parent)
|
||||||
|
|
||||||
|
def test_rejects_database_symlink_without_changing_target(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
parent = Path(d) / "store"
|
||||||
|
parent.mkdir()
|
||||||
|
target = Path(d) / "target"
|
||||||
|
target.touch(mode=0o644)
|
||||||
|
(parent / "test.db").symlink_to(target)
|
||||||
|
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
_store(parent)
|
||||||
|
|
||||||
|
self.assertEqual(0o644, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
|
||||||
def test_returns_false_when_schema_versions_missing(self):
|
def test_returns_false_when_schema_versions_missing(self):
|
||||||
# DB file exists but has no schema_versions table → OperationalError → False.
|
# DB file exists but has no schema_versions table → OperationalError → False.
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ from __future__ import annotations
|
|||||||
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 import EnumerationError
|
||||||
|
from bot_bottle.backend.docker import cleanup
|
||||||
from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
|
from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
|
||||||
|
|
||||||
|
|
||||||
@@ -116,5 +119,37 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthoritativeDiscovery(unittest.TestCase):
|
||||||
|
def test_prepare_requires_authoritative_compose_projects(self):
|
||||||
|
with patch.object(cleanup.docker_mod, "require_docker"), \
|
||||||
|
patch.object(
|
||||||
|
cleanup, "list_compose_projects",
|
||||||
|
side_effect=EnumerationError("compose unavailable"),
|
||||||
|
) as projects, self.assertRaisesRegex(
|
||||||
|
EnumerationError, "compose unavailable",
|
||||||
|
):
|
||||||
|
cleanup.prepare_cleanup()
|
||||||
|
projects.assert_called_once_with(
|
||||||
|
warn_on_error=False,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_container_query_failure_raises(self):
|
||||||
|
failed = cleanup.subprocess.CompletedProcess(
|
||||||
|
[], 1, stdout="", stderr="daemon unavailable",
|
||||||
|
)
|
||||||
|
with patch.object(cleanup.subprocess, "run", return_value=failed), \
|
||||||
|
self.assertRaisesRegex(EnumerationError, "daemon unavailable"):
|
||||||
|
cleanup._list_prefixed_containers()
|
||||||
|
|
||||||
|
def test_network_query_failure_raises(self):
|
||||||
|
failed = cleanup.subprocess.CompletedProcess(
|
||||||
|
[], 1, stdout="", stderr="daemon unavailable",
|
||||||
|
)
|
||||||
|
with patch.object(cleanup.subprocess, "run", return_value=failed), \
|
||||||
|
self.assertRaisesRegex(EnumerationError, "daemon unavailable"):
|
||||||
|
cleanup._list_prefixed_networks()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -32,15 +32,19 @@ class TestProcessScan(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
Path("/cache/run/dev-a"),
|
Path("/cache/run/dev-a"),
|
||||||
fc_cleanup._run_dir_of(
|
fc_cleanup._run_dir_of(
|
||||||
f"firecracker --config-file {run_root}/dev-a/config.json", run_root
|
("firecracker", "--config-file",
|
||||||
|
f"{run_root}/dev-a/config.json"), run_root
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# infra/builder VMs elsewhere, or nested paths, are not ours.
|
# infra/builder VMs elsewhere, or nested paths, are not ours.
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
fc_cleanup._run_dir_of("firecracker --config-file /elsewhere/config.json", run_root)
|
fc_cleanup._run_dir_of(
|
||||||
|
("firecracker", "--config-file", "/elsewhere/config.json"),
|
||||||
|
run_root,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
fc_cleanup._run_dir_of("firecracker --no-config", run_root)
|
fc_cleanup._run_dir_of(("firecracker", "--no-config"), run_root)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_scan_splits_live_dirs_from_orphan_pids(self):
|
def test_scan_splits_live_dirs_from_orphan_pids(self):
|
||||||
@@ -48,17 +52,40 @@ class TestProcessScan(unittest.TestCase):
|
|||||||
run_root = Path(tmp)
|
run_root = Path(tmp)
|
||||||
(run_root / "live-a").mkdir() # dir present -> live VM, protected
|
(run_root / "live-a").mkdir() # dir present -> live VM, protected
|
||||||
# "gone-b" dir intentionally absent -> lingering VMM, orphan pid
|
# "gone-b" dir intentionally absent -> lingering VMM, orphan pid
|
||||||
out = (
|
args = {
|
||||||
f"111 firecracker --config-file {run_root}/live-a/config.json\n"
|
111: ("firecracker", "--config-file",
|
||||||
f"222 firecracker --config-file {run_root}/gone-b/config.json\n"
|
f"{run_root}/live-a/config.json"),
|
||||||
"333 firecracker --config-file /elsewhere/config.json\n"
|
222: ("firecracker", "--config-file",
|
||||||
"notanint firecracker --config-file x\n"
|
f"{run_root}/gone-b/config.json"),
|
||||||
)
|
333: ("firecracker", "--config-file", "/elsewhere/config.json"),
|
||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
|
}
|
||||||
|
with patch.object(
|
||||||
|
fc_cleanup.subprocess, "run",
|
||||||
|
return_value=_proc("111\n222\n333\nnotanint\n"),
|
||||||
|
), patch.object(
|
||||||
|
fc_cleanup, "_process_args", side_effect=args.get,
|
||||||
|
):
|
||||||
live, orphan_pids = fc_cleanup._scan_processes(run_root)
|
live, orphan_pids = fc_cleanup._scan_processes(run_root)
|
||||||
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_preserves_spaces_in_config_path(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="fc cache ") as tmp:
|
||||||
|
run_root = Path(tmp)
|
||||||
|
live = run_root / "live bottle"
|
||||||
|
live.mkdir()
|
||||||
|
with patch.object(
|
||||||
|
fc_cleanup.subprocess, "run", return_value=_proc("111\n"),
|
||||||
|
), patch.object(
|
||||||
|
fc_cleanup, "_process_args",
|
||||||
|
return_value=("firecracker", "--config-file",
|
||||||
|
str(live / "config.json")),
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
({str(live)}, []),
|
||||||
|
fc_cleanup._scan_processes(run_root),
|
||||||
|
)
|
||||||
|
|
||||||
def test_scan_empty_when_pgrep_finds_no_processes(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")))
|
||||||
@@ -117,9 +144,14 @@ class TestProcessScan(unittest.TestCase):
|
|||||||
run_root = Path(tmp)
|
run_root = Path(tmp)
|
||||||
(run_root / "live-a").mkdir()
|
(run_root / "live-a").mkdir()
|
||||||
(run_root / "dead-b").mkdir()
|
(run_root / "dead-b").mkdir()
|
||||||
out = f"111 firecracker --config-file {run_root}/live-a/config.json\n"
|
|
||||||
with patch.object(fc_cleanup, "_run_root", return_value=run_root), \
|
with patch.object(fc_cleanup, "_run_root", return_value=run_root), \
|
||||||
patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
|
patch.object(fc_cleanup.subprocess, "run",
|
||||||
|
return_value=_proc("111\n")), \
|
||||||
|
patch.object(
|
||||||
|
fc_cleanup, "_process_args",
|
||||||
|
return_value=("firecracker", "--config-file",
|
||||||
|
str(run_root / "live-a/config.json")),
|
||||||
|
):
|
||||||
plan = fc_cleanup.prepare_cleanup()
|
plan = fc_cleanup.prepare_cleanup()
|
||||||
self.assertEqual((), plan.vm_pids)
|
self.assertEqual((), plan.vm_pids)
|
||||||
self.assertEqual((str(run_root / "dead-b"),), plan.run_dirs)
|
self.assertEqual((str(run_root / "dead-b"),), plan.run_dirs)
|
||||||
@@ -132,18 +164,46 @@ class TestCleanupRemoval(unittest.TestCase):
|
|||||||
vm_pids=(101,),
|
vm_pids=(101,),
|
||||||
run_dirs=("/run/dev-x",),
|
run_dirs=("/run/dev-x",),
|
||||||
)
|
)
|
||||||
with patch.object(fc_cleanup.os, "kill") as kill, \
|
with patch.object(fc_cleanup, "prepare_cleanup", return_value=plan), \
|
||||||
patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \
|
patch.object(fc_cleanup, "_run_root", return_value=Path("/run")), \
|
||||||
|
patch.object(fc_cleanup, "_terminate_orphan") as terminate, \
|
||||||
|
patch("bot_bottle.backend.cleanup_control.shutil.rmtree") as rmtree, \
|
||||||
patch.object(fc_cleanup, "info"):
|
patch.object(fc_cleanup, "info"):
|
||||||
fc_cleanup.cleanup(plan)
|
fc_cleanup.cleanup(plan)
|
||||||
kill.assert_called_once()
|
terminate.assert_called_once_with(101, Path("/run"))
|
||||||
rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True)
|
rmtree.assert_called_once_with(Path("/run/dev-x"))
|
||||||
|
|
||||||
def test_cleanup_tolerates_dead_pid(self):
|
def test_cleanup_skips_resources_no_longer_in_refreshed_plan(self):
|
||||||
plan = FirecrackerBottleCleanupPlan(vm_pids=(999,))
|
preview = FirecrackerBottleCleanupPlan(
|
||||||
with patch.object(fc_cleanup.os, "kill", side_effect=ProcessLookupError), \
|
vm_pids=(999,), run_dirs=("/run/reused",),
|
||||||
patch.object(fc_cleanup, "info"):
|
)
|
||||||
fc_cleanup.cleanup(plan) # must not raise
|
with patch.object(
|
||||||
|
fc_cleanup, "prepare_cleanup",
|
||||||
|
return_value=FirecrackerBottleCleanupPlan(),
|
||||||
|
), patch.object(fc_cleanup, "_terminate_orphan") as terminate, \
|
||||||
|
patch("bot_bottle.backend.cleanup_control.shutil.rmtree") as rmtree:
|
||||||
|
fc_cleanup.cleanup(preview)
|
||||||
|
terminate.assert_not_called()
|
||||||
|
rmtree.assert_not_called()
|
||||||
|
|
||||||
|
def test_pidfd_prevents_pid_reuse_from_signalling_unrelated_process(self):
|
||||||
|
with patch.object(fc_cleanup.os, "pidfd_open", return_value=7), \
|
||||||
|
patch.object(
|
||||||
|
fc_cleanup.Path, "read_bytes",
|
||||||
|
return_value=b"/usr/bin/python\0worker.py\0",
|
||||||
|
), patch.object(fc_cleanup.signal, "pidfd_send_signal") as send, \
|
||||||
|
patch.object(fc_cleanup.os, "close"):
|
||||||
|
fc_cleanup._terminate_orphan(101, Path("/run"))
|
||||||
|
send.assert_not_called()
|
||||||
|
|
||||||
|
def test_pidfd_signals_revalidated_orphan(self):
|
||||||
|
command = b"firecracker\0--config-file\0/run/gone/config.json\0"
|
||||||
|
with patch.object(fc_cleanup.os, "pidfd_open", return_value=7), \
|
||||||
|
patch.object(fc_cleanup.Path, "read_bytes", return_value=command), \
|
||||||
|
patch.object(fc_cleanup.signal, "pidfd_send_signal") as send, \
|
||||||
|
patch.object(fc_cleanup.os, "close"), patch.object(fc_cleanup, "info"):
|
||||||
|
fc_cleanup._terminate_orphan(101, Path("/run"))
|
||||||
|
send.assert_called_once_with(7, fc_cleanup.signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupPlan(unittest.TestCase):
|
class TestCleanupPlan(unittest.TestCase):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Unit tests for shared gateway stdlib HTTP resource boundaries."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import socket
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from bot_bottle.gateway.bounded_http import (
|
||||||
|
BodyReadError,
|
||||||
|
BoundedThreadingHTTPServer,
|
||||||
|
read_declared_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _TimeoutStream:
|
||||||
|
def read(self, _size: int = -1, /) -> bytes:
|
||||||
|
raise TimeoutError
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeclaredBody(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.left, self.right = socket.socketpair()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.left.close()
|
||||||
|
self.right.close()
|
||||||
|
|
||||||
|
def test_rejects_incomplete_body(self) -> None:
|
||||||
|
with self.assertRaisesRegex(BodyReadError, "incomplete"):
|
||||||
|
read_declared_body(
|
||||||
|
io.BytesIO(b"short"),
|
||||||
|
self.left,
|
||||||
|
"10",
|
||||||
|
maximum=100,
|
||||||
|
timeout_seconds=1,
|
||||||
|
require_length=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_maps_read_timeout(self) -> None:
|
||||||
|
with self.assertRaises(BodyReadError) as caught:
|
||||||
|
read_declared_body(
|
||||||
|
_TimeoutStream(),
|
||||||
|
self.left,
|
||||||
|
"1",
|
||||||
|
maximum=100,
|
||||||
|
timeout_seconds=1,
|
||||||
|
require_length=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(408, caught.exception.status)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoundedServer(unittest.TestCase):
|
||||||
|
def test_saturated_server_rejects_without_spawning_thread(self) -> None:
|
||||||
|
client, peer = socket.socketpair()
|
||||||
|
with BoundedThreadingHTTPServer(
|
||||||
|
("127.0.0.1", 0), _Handler, max_workers=1, # type: ignore[arg-type]
|
||||||
|
) as server:
|
||||||
|
with client, peer:
|
||||||
|
# Directly reserve the only slot to model an in-flight handler.
|
||||||
|
self.assertTrue(
|
||||||
|
server._request_slots.acquire( # pylint: disable=consider-using-with
|
||||||
|
blocking=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
server.process_request(client, ("127.0.0.1", 1))
|
||||||
|
self.assertIn(b"503 Service Unavailable", peer.recv(1024))
|
||||||
|
finally:
|
||||||
|
server._request_slots.release()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -23,6 +23,7 @@ from bot_bottle.gateway.bootstrap import (
|
|||||||
_DaemonManager,
|
_DaemonManager,
|
||||||
_argv_for_daemon,
|
_argv_for_daemon,
|
||||||
_env_for_daemon,
|
_env_for_daemon,
|
||||||
|
_pump,
|
||||||
_selected_daemons,
|
_selected_daemons,
|
||||||
)
|
)
|
||||||
from tests._bin import SLEEP
|
from tests._bin import SLEEP
|
||||||
@@ -565,5 +566,26 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
self.assertIn("no daemons selected", out)
|
self.assertIn("no daemons selected", out)
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingStream:
|
||||||
|
def __init__(self, *, closed: bool) -> None:
|
||||||
|
self.closed = closed
|
||||||
|
|
||||||
|
def readline(self) -> bytes:
|
||||||
|
raise ValueError("I/O operation on closed file")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutputPump(unittest.TestCase):
|
||||||
|
def test_closed_stream_race_is_normal_completion(self) -> None:
|
||||||
|
with patch("bot_bottle.gateway.bootstrap._log") as log:
|
||||||
|
_pump("egress", _FailingStream(closed=True)) # type: ignore[arg-type]
|
||||||
|
log.assert_not_called()
|
||||||
|
|
||||||
|
def test_unexpected_io_failure_is_logged(self) -> None:
|
||||||
|
with patch("bot_bottle.gateway.bootstrap._log") as log:
|
||||||
|
_pump("egress", _FailingStream(closed=False)) # type: ignore[arg-type]
|
||||||
|
log.assert_called_once()
|
||||||
|
self.assertIn("output pump stopped", log.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -486,6 +487,19 @@ class TestMalformedStatusHeader(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(500, status)
|
self.assertEqual(500, status)
|
||||||
|
|
||||||
|
def test_backend_timeout_returns_503(self):
|
||||||
|
with mock.patch(
|
||||||
|
"bot_bottle.gateway.git_gate.http_backend.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired(["git", "http-backend"], 1),
|
||||||
|
):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{self._port}/repo.git/info/refs",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||||
|
urllib.request.urlopen(req, timeout=3)
|
||||||
|
self.assertEqual(503, raised.exception.code)
|
||||||
|
|
||||||
|
|
||||||
class TestContentLengthBounds(unittest.TestCase):
|
class TestContentLengthBounds(unittest.TestCase):
|
||||||
"""PRD 0041: malformed or oversized Content-Length is rejected before
|
"""PRD 0041: malformed or oversized Content-Length is rejected before
|
||||||
|
|||||||
@@ -284,6 +284,18 @@ class TestEnsureArtifact(_CacheMixin):
|
|||||||
ia.ensure_artifact_gz(version, role=_ROLE)
|
ia.ensure_artifact_gz(version, role=_ROLE)
|
||||||
self.assertEqual(first_calls, len(net.calls))
|
self.assertEqual(first_calls, len(net.calls))
|
||||||
|
|
||||||
|
def test_download_uses_network_deadline(self) -> None:
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.__enter__.return_value = io.BytesIO(b"payload")
|
||||||
|
with tempfile.TemporaryDirectory() as d, mock.patch.object(
|
||||||
|
ia.urllib.request, "urlopen", return_value=response,
|
||||||
|
) as urlopen:
|
||||||
|
ia._download("https://registry/artifact", Path(d) / "artifact")
|
||||||
|
self.assertEqual(
|
||||||
|
ia.ARTIFACT_HTTP_TIMEOUT_SECONDS,
|
||||||
|
urlopen.call_args.kwargs["timeout"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_checksum_mismatch_fails_closed(self) -> None:
|
def test_checksum_mismatch_fails_closed(self) -> None:
|
||||||
version = "beefbeefbeefbeef"
|
version = "beefbeefbeefbeef"
|
||||||
gz = _gz(b"payload")
|
gz = _gz(b"payload")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.backend import EnumerationError
|
from bot_bottle.backend import EnumerationError
|
||||||
|
from bot_bottle.backend.cleanup_control import CleanupError
|
||||||
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,
|
||||||
@@ -31,7 +32,10 @@ class TestMacosContainerCleanup(unittest.TestCase):
|
|||||||
containers=("bot-bottle-a",),
|
containers=("bot-bottle-a",),
|
||||||
networks=("bot-bottle-net-a",),
|
networks=("bot-bottle-net-a",),
|
||||||
)
|
)
|
||||||
with patch.object(cleanup.subprocess, "run") as run:
|
completed = cleanup.subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout="", stderr="",
|
||||||
|
)
|
||||||
|
with patch.object(cleanup.subprocess, "run", return_value=completed) as run:
|
||||||
cleanup.cleanup(plan)
|
cleanup.cleanup(plan)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["container", "delete", "--force", "bot-bottle-a"],
|
["container", "delete", "--force", "bot-bottle-a"],
|
||||||
@@ -42,6 +46,34 @@ class TestMacosContainerCleanup(unittest.TestCase):
|
|||||||
run.call_args_list[1].args[0],
|
run.call_args_list[1].args[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_cleanup_attempts_all_resources_then_raises(self):
|
||||||
|
plan = MacosContainerBottleCleanupPlan(
|
||||||
|
containers=("bot-bottle-a",), networks=("bot-bottle-net-a",),
|
||||||
|
)
|
||||||
|
failed = cleanup.subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="unavailable",
|
||||||
|
)
|
||||||
|
with patch.object(cleanup.subprocess, "run", return_value=failed) as run, \
|
||||||
|
self.assertRaisesRegex(CleanupError, "unavailable"):
|
||||||
|
cleanup.cleanup(plan)
|
||||||
|
self.assertEqual(2, run.call_count)
|
||||||
|
|
||||||
|
def test_container_enumeration_failure_aborts(self):
|
||||||
|
completed = cleanup.subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="service unavailable",
|
||||||
|
)
|
||||||
|
with patch.object(cleanup.subprocess, "run", return_value=completed), \
|
||||||
|
self.assertRaisesRegex(EnumerationError, "service unavailable"):
|
||||||
|
cleanup._list_prefixed_containers()
|
||||||
|
|
||||||
|
def test_network_enumeration_failure_aborts(self):
|
||||||
|
completed = cleanup.subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="service unavailable",
|
||||||
|
)
|
||||||
|
with patch.object(cleanup.subprocess, "run", return_value=completed), \
|
||||||
|
self.assertRaisesRegex(EnumerationError, "service unavailable"):
|
||||||
|
cleanup._list_prefixed_networks()
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerEnumerate(unittest.TestCase):
|
class TestMacosContainerEnumerate(unittest.TestCase):
|
||||||
"""The backend launches bottles again (PRD 0070), so enumeration is real
|
"""The backend launches bottles again (PRD 0070), so enumeration is real
|
||||||
|
|||||||
@@ -224,6 +224,17 @@ class TestAgentSecrets(unittest.TestCase):
|
|||||||
reopened = RegistryStore(self.db)
|
reopened = RegistryStore(self.db)
|
||||||
self.assertEqual({"K": "v"}, reopened.get_agent_secrets("bottle-1"))
|
self.assertEqual({"K": "v"}, reopened.get_agent_secrets("bottle-1"))
|
||||||
|
|
||||||
|
def test_v6_migration_clears_legacy_secret_rows(self) -> None:
|
||||||
|
self.store.store_agent_secrets("bottle-1", {"K": "legacy"})
|
||||||
|
with closing(sqlite3.connect(self.db)) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE schema_versions SET version = 5 "
|
||||||
|
"WHERE module = 'orchestrator_registry'"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
self.store.migrate()
|
||||||
|
self.assertEqual({}, self.store.get_agent_secrets("bottle-1"))
|
||||||
|
|
||||||
|
|
||||||
class TestReapAbsent(unittest.TestCase):
|
class TestReapAbsent(unittest.TestCase):
|
||||||
"""`reap_absent` — the self-heal for rows whose bottle is gone.
|
"""`reap_absent` — the self-heal for rows whose bottle is gone.
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
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 (
|
||||||
@@ -83,16 +81,21 @@ class TestDecryptErrors(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "authentication failed"):
|
with self.assertRaisesRegex(ValueError, "authentication failed"):
|
||||||
decrypt_value(self.secret, tampered)
|
decrypt_value(self.secret, tampered)
|
||||||
|
|
||||||
def test_reads_legacy_ciphertext_for_migration(self) -> None:
|
def test_rejects_legacy_ciphertext(self) -> None:
|
||||||
key = base64.urlsafe_b64decode(self.secret + "==")
|
legacy = base64.urlsafe_b64encode(
|
||||||
nonce = b"0123456789abcdef"
|
b"0123456789abcdeflegacy-token",
|
||||||
plaintext = b"legacy-token"
|
).rstrip(b"=").decode()
|
||||||
stream = hmac.new(
|
with self.assertRaisesRegex(ValueError, "unsupported ciphertext format"):
|
||||||
key, nonce + (0).to_bytes(4, "big"), hashlib.sha256,
|
decrypt_value(self.secret, legacy)
|
||||||
).digest()
|
|
||||||
ciphertext = bytes(p ^ k for p, k in zip(plaintext, stream))
|
def test_rejects_authenticated_blob_with_changed_version(self) -> None:
|
||||||
legacy = base64.urlsafe_b64encode(nonce + ciphertext).rstrip(b"=").decode()
|
raw = bytearray(base64.urlsafe_b64decode(
|
||||||
self.assertEqual("legacy-token", decrypt_value(self.secret, legacy))
|
encrypt_value(self.secret, "secret-token") + "=="
|
||||||
|
))
|
||||||
|
raw[0] ^= 1
|
||||||
|
downgraded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||||
|
with self.assertRaisesRegex(ValueError, "unsupported ciphertext format"):
|
||||||
|
decrypt_value(self.secret, downgraded)
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -70,15 +70,6 @@ def dispatch(
|
|||||||
|
|
||||||
response = asyncio.run(request())
|
response = asyncio.run(request())
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
if response.status_code == 422:
|
|
||||||
detail = payload.get("detail", []) if isinstance(payload, dict) else []
|
|
||||||
field = ""
|
|
||||||
if isinstance(detail, list) and detail and isinstance(detail[0], dict):
|
|
||||||
location = detail[0].get("loc", ())
|
|
||||||
if isinstance(location, (list, tuple)) and len(location) > 1:
|
|
||||||
field = str(location[1])
|
|
||||||
suffix = f": {field}" if field else ""
|
|
||||||
return 400, {"error": f"invalid request body{suffix}"}
|
|
||||||
if isinstance(payload, dict) and "detail" in payload and "error" not in payload:
|
if isinstance(payload, dict) and "detail" in payload and "error" not in payload:
|
||||||
payload = {"error": payload["detail"]}
|
payload = {"error": payload["detail"]}
|
||||||
return response.status_code, payload
|
return response.status_code, payload
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ class TestProvisionGitGate(unittest.TestCase):
|
|||||||
def test_copies_creds_and_runs_namespaced_init(self) -> None:
|
def test_copies_creds_and_runs_namespaced_init(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
with patch(_RUN, side_effect=_recorder(calls)):
|
||||||
provision_git_gate(DockerGatewayTransport("gw"), "bottle1", _plan(_up("foo", known_hosts="/host/kh")))
|
provision_git_gate(
|
||||||
|
DockerGatewayTransport("gw"),
|
||||||
|
"bottle1",
|
||||||
|
_plan(_up("foo", known_hosts="/host/kh")),
|
||||||
|
)
|
||||||
|
|
||||||
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
|
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
|
||||||
self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps)
|
self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps)
|
||||||
@@ -81,11 +85,39 @@ class TestProvisionGitGate(unittest.TestCase):
|
|||||||
["docker", "exec", "gw", "chmod", "+x", "/etc/git-gate/access-hook"], calls,
|
["docker", "exec", "gw", "chmod", "+x", "/etc/git-gate/access-hook"], calls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_applies_private_modes_inside_gateway(self) -> None:
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
with patch(_RUN, side_effect=_recorder(calls)):
|
||||||
|
provision_git_gate(
|
||||||
|
DockerGatewayTransport("gw"),
|
||||||
|
"bottle1",
|
||||||
|
_plan(_up("foo", known_hosts="/host/kh")),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
[
|
||||||
|
"docker", "exec", "gw", "chmod", "700",
|
||||||
|
"/git-gate/creds/bottle1",
|
||||||
|
],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
[
|
||||||
|
"docker", "exec", "gw", "chmod", "600",
|
||||||
|
"/git-gate/creds/bottle1/foo-key",
|
||||||
|
"/git-gate/creds/bottle1/foo-known_hosts",
|
||||||
|
],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
|
||||||
def test_omits_known_hosts_copy_when_absent(self) -> None:
|
def test_omits_known_hosts_copy_when_absent(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
with patch(_RUN, side_effect=_recorder(calls)):
|
||||||
provision_git_gate(DockerGatewayTransport("gw"), "b1", _plan(_up("foo"))) # no known_hosts
|
# No known-hosts file: only the identity key is copied.
|
||||||
creds_cps = [c for c in calls if c[:2] == ["docker", "cp"] and "/git-gate/creds/" in c[3]]
|
provision_git_gate(DockerGatewayTransport("gw"), "b1", _plan(_up("foo")))
|
||||||
|
creds_cps = [
|
||||||
|
c for c in calls
|
||||||
|
if c[:2] == ["docker", "cp"] and "/git-gate/creds/" in c[3]
|
||||||
|
]
|
||||||
self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts
|
self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts
|
||||||
self.assertTrue(creds_cps[0][3].endswith("/foo-key"))
|
self.assertTrue(creds_cps[0][3].endswith("/foo-key"))
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class TestPut(unittest.TestCase):
|
|||||||
self.assertNotIsInstance(req.data, (bytes, bytearray))
|
self.assertNotIsInstance(req.data, (bytes, bytearray))
|
||||||
self.assertEqual(str(len(payload)), req.get_header("Content-length"))
|
self.assertEqual(str(len(payload)), req.get_header("Content-length"))
|
||||||
|
|
||||||
|
def test_put_uses_network_deadline(self) -> None:
|
||||||
|
with mock.patch.object(
|
||||||
|
pub.urllib.request, "urlopen", return_value=_Resp(),
|
||||||
|
) as urlopen:
|
||||||
|
pub._put("https://reg/pkg", b"payload", token="")
|
||||||
|
self.assertEqual(
|
||||||
|
pub._REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||||
|
urlopen.call_args.kwargs["timeout"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_small_bytes_body_still_works(self) -> None:
|
def test_small_bytes_body_still_works(self) -> None:
|
||||||
captured: list[urllib.request.Request] = []
|
captured: list[urllib.request.Request] = []
|
||||||
|
|
||||||
|
|||||||
@@ -136,12 +136,13 @@ class TestStoreGuardBranches(unittest.TestCase):
|
|||||||
db.unlink()
|
db.unlink()
|
||||||
self.assertEqual([], store.list_all_pending_proposals())
|
self.assertEqual([], store.list_all_pending_proposals())
|
||||||
|
|
||||||
def test_queue_store_chmod_oserror_is_swallowed(self):
|
def test_queue_store_chmod_oserror_fails_closed(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
db = Path(d) / "q.db"
|
db = Path(d) / "q.db"
|
||||||
store = QueueStore("key", db_path=db)
|
store = QueueStore("key", db_path=db)
|
||||||
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
|
with patch("os.fchmod", side_effect=OSError("ro")):
|
||||||
store.migrate() # must not raise
|
with self.assertRaisesRegex(OSError, "ro"):
|
||||||
|
store.migrate()
|
||||||
|
|
||||||
def test_audit_store_missing_db_read_returns_empty(self):
|
def test_audit_store_missing_db_read_returns_empty(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
@@ -151,12 +152,13 @@ class TestStoreGuardBranches(unittest.TestCase):
|
|||||||
db.unlink()
|
db.unlink()
|
||||||
self.assertEqual([], store.read_audit_entries("egress", "slug"))
|
self.assertEqual([], store.read_audit_entries("egress", "slug"))
|
||||||
|
|
||||||
def test_audit_store_chmod_oserror_is_swallowed(self):
|
def test_audit_store_chmod_oserror_fails_closed(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
db = Path(d) / "a.db"
|
db = Path(d) / "a.db"
|
||||||
store = AuditStore(db_path=db)
|
store = AuditStore(db_path=db)
|
||||||
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
|
with patch("os.fchmod", side_effect=OSError("ro")):
|
||||||
store.migrate() # must not raise
|
with self.assertRaisesRegex(OSError, "ro"):
|
||||||
|
store.migrate()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -717,18 +717,26 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
hosts = {r["host"] for r in data["routes"]}
|
hosts = {r["host"] for r in data["routes"]}
|
||||||
self.assertEqual({"api.anthropic.com", "www.google.com"}, hosts)
|
self.assertEqual({"api.anthropic.com", "www.google.com"}, hosts)
|
||||||
|
|
||||||
def test_orchestrator_error_fails_closed_to_empty(self) -> None:
|
def test_orchestrator_error_is_not_reported_as_empty_policy(self) -> None:
|
||||||
# resolve_client_context swallows resolver errors → deny-all (empty),
|
with self.assertRaises(supervise_server.RouteResolutionError):
|
||||||
# never another bottle's routes.
|
resolved_routes_payload(
|
||||||
|
typing.cast(
|
||||||
|
supervise_server.PolicyResolver,
|
||||||
|
_FakeSuperviseResolver(raises=True),
|
||||||
|
),
|
||||||
|
_SRC,
|
||||||
|
_TOK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authoritative_empty_policy_remains_successful(self) -> None:
|
||||||
payload = resolved_routes_payload(
|
payload = resolved_routes_payload(
|
||||||
typing.cast(
|
typing.cast(
|
||||||
supervise_server.PolicyResolver,
|
supervise_server.PolicyResolver,
|
||||||
_FakeSuperviseResolver(raises=True),
|
_FakeSuperviseResolver(bottle_id="b1", policy="routes: []\n"),
|
||||||
),
|
),
|
||||||
_SRC,
|
_SRC,
|
||||||
_TOK,
|
_TOK,
|
||||||
)
|
)
|
||||||
assert payload is not None
|
|
||||||
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
||||||
self.assertEqual([], data["routes"])
|
self.assertEqual([], data["routes"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user