Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 784c7ac152 | |||
| 4a4d3b2455 | |||
| 715e474306 | |||
| ecd260b5ef | |||
| 7302ed6518 | |||
| 220f92af5b | |||
| 6a086acb4a | |||
| c4f16c168c | |||
| d5ac323d9c | |||
| 9c4400cce2 | |||
| 62d2e86e7e | |||
| 2b53c36608 | |||
| bb9cca48fd | |||
| 5828668e21 | |||
| 869a8bbc1f | |||
| 4863e81cd3 | |||
| 814c7338a1 |
+19
-4
@@ -14,9 +14,10 @@
|
|||||||
# /app/supervise_server.py + .py supervise MCP server
|
# /app/supervise_server.py + .py supervise MCP server
|
||||||
# /app/sidecar_init.py PID 1 supervisor
|
# /app/sidecar_init.py PID 1 supervisor
|
||||||
# /etc/egress/routes.yaml bind-mounted at run time
|
# /etc/egress/routes.yaml bind-mounted at run time
|
||||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
# /etc/git-gate/entrypoint.sh per-bottle (docker-cp or virtiofs mount)
|
||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
# /etc/git-gate/pre-receive per-bottle (docker-cp or virtiofs mount)
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
# /git-gate-entrypoint.sh static wrapper → /etc/git-gate/entrypoint.sh
|
||||||
|
# /git-gate/creds/* per-bottle (docker-cp or virtiofs mount)
|
||||||
# /git/* bare repos, populated at runtime
|
# /git/* bare repos, populated at runtime
|
||||||
# /run/supervise/bot-bottle.db bind-mounted at run time
|
# /run/supervise/bot-bottle.db bind-mounted at run time
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||||
@@ -88,7 +89,21 @@ RUN mkdir -p \
|
|||||||
/git-gate/creds \
|
/git-gate/creds \
|
||||||
/git \
|
/git \
|
||||||
/run/supervise \
|
/run/supervise \
|
||||||
/home/mitmproxy/.mitmproxy
|
/home/mitmproxy/.mitmproxy \
|
||||||
|
/bot-bottle-data/egress \
|
||||||
|
/bot-bottle-data/git-gate
|
||||||
|
|
||||||
|
# Static wrapper for the git-gate entrypoint. The per-bottle
|
||||||
|
# entrypoint script is either:
|
||||||
|
# - docker-cp'd to /git-gate-entrypoint.sh (docker/macOS backends),
|
||||||
|
# which overwrites this wrapper; or
|
||||||
|
# - virtiofs-mounted at /bot-bottle-data/git-gate/entrypoint.sh
|
||||||
|
# (smolmachines), where this wrapper delegates to it at runtime.
|
||||||
|
# Fallback to /etc/git-gate/ for backwards compatibility.
|
||||||
|
# Either way sidecar_init.py calls `/bin/sh /git-gate-entrypoint.sh`.
|
||||||
|
RUN printf '#!/bin/sh\nif [ -x /bot-bottle-data/git-gate/entrypoint.sh ]; then\n exec /bot-bottle-data/git-gate/entrypoint.sh "$@"\nfi\nexec /etc/git-gate/entrypoint.sh "$@"\n' \
|
||||||
|
> /git-gate-entrypoint.sh \
|
||||||
|
&& chmod 755 /git-gate-entrypoint.sh
|
||||||
|
|
||||||
# Documentation only — the compose renderer publishes whichever
|
# Documentation only — the compose renderer publishes whichever
|
||||||
# subset the bottle uses.
|
# subset the bottle uses.
|
||||||
|
|||||||
@@ -7,16 +7,23 @@ exec`` instead of Docker.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...bottle_state import egress_state_dir
|
||||||
from ...log import warn
|
from ...log import warn
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
|
|
||||||
|
# Routes file path inside the sidecar VM. Set via EGRESS_ROUTES env var
|
||||||
|
# at launch so the addon reads from the virtiofs-mounted confdir instead
|
||||||
|
# of the default /etc/egress/routes.yaml.
|
||||||
|
_EGRESS_ROUTES_IN_SIDECAR_VM = "/bot-bottle-data/egress/routes.yaml"
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
def fetch_current_routes(slug: str) -> str:
|
||||||
machine = _bundle.bundle_machine_name(slug)
|
machine = _bundle.bundle_machine_name(slug)
|
||||||
result = _smolvm.machine_exec(machine, ["cat", EGRESS_ROUTES_IN_CONTAINER])
|
result = _smolvm.machine_exec(machine, ["cat", _EGRESS_ROUTES_IN_SIDECAR_VM])
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise EgressApplyError(
|
raise EgressApplyError(
|
||||||
f"could not read routes.yaml from {machine}: "
|
f"could not read routes.yaml from {machine}: "
|
||||||
@@ -26,6 +33,14 @@ def fetch_current_routes(slug: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class SmolmachinesEgressApplicator(EgressApplicator):
|
class SmolmachinesEgressApplicator(EgressApplicator):
|
||||||
|
@staticmethod
|
||||||
|
def _routes_path(slug: str) -> Path:
|
||||||
|
# Routes live in the smolvm-sidecar-data staging dir, which is
|
||||||
|
# virtiofs-mounted at /bot-bottle-data/ in the sidecar VM. Writes
|
||||||
|
# here are visible inside the VM immediately; a SIGHUP causes the
|
||||||
|
# addon to reload from _EGRESS_ROUTES_IN_SIDECAR_VM.
|
||||||
|
return egress_state_dir(slug) / "smolvm-sidecar-data" / "egress" / "routes.yaml"
|
||||||
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
def _signal_bundle_reload(self, slug: str) -> None:
|
||||||
machine = _bundle.bundle_machine_name(slug)
|
machine = _bundle.bundle_machine_name(slug)
|
||||||
result = _smolvm.machine_exec(machine, ["sh", "-c", "kill -HUP 1"])
|
result = _smolvm.machine_exec(machine, ["sh", "-c", "kill -HUP 1"])
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...egress import (
|
from ...egress import (
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_agent_env_entries,
|
egress_agent_env_entries,
|
||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
egress_sidecar_env_entries,
|
egress_sidecar_env_entries,
|
||||||
@@ -28,16 +28,9 @@ from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
|||||||
from ...util import expand_tilde
|
from ...util import expand_tilde
|
||||||
from ..docker import util as docker_mod
|
from ..docker import util as docker_mod
|
||||||
from ..docker.egress import (
|
from ..docker.egress import (
|
||||||
EGRESS_CA_IN_CONTAINER,
|
|
||||||
EGRESS_PORT as _EGRESS_PORT,
|
EGRESS_PORT as _EGRESS_PORT,
|
||||||
egress_tls_init,
|
egress_tls_init,
|
||||||
)
|
)
|
||||||
from ..docker.git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from ...git_gate import (
|
from ...git_gate import (
|
||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
@@ -63,6 +56,18 @@ from .local_registry import crane_push_tarball, ephemeral_registry
|
|||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
|
||||||
|
|
||||||
|
# Single virtiofs mount for egress + git-gate files. libkrun limits
|
||||||
|
# the total of mounts + port-mappings to 5; with 3 daemon ports the
|
||||||
|
# sidecar VM can carry at most 2 mounts. Egress CA/routes and
|
||||||
|
# git-gate scripts/creds are staged into subdirectories of one host
|
||||||
|
# dir and mounted here. Env vars (EGRESS_CONFDIR, EGRESS_ROUTES,
|
||||||
|
# and the Dockerfile's git-gate wrapper) point each daemon at its
|
||||||
|
# subdirectory.
|
||||||
|
_SIDECAR_DATA_DIR_IN_VM = "/bot-bottle-data"
|
||||||
|
_EGRESS_CONFDIR_IN_VM = f"{_SIDECAR_DATA_DIR_IN_VM}/egress"
|
||||||
|
_GIT_GATE_SCRIPTS_DIR_IN_VM = f"{_SIDECAR_DATA_DIR_IN_VM}/git-gate"
|
||||||
|
|
||||||
|
|
||||||
# Per-host cache for `smolvm pack create` outputs. Keyed by the
|
# Per-host cache for `smolvm pack create` outputs. Keyed by the
|
||||||
# docker image ID so a Dockerfile change automatically invalidates
|
# docker image ID so a Dockerfile change automatically invalidates
|
||||||
# the cache. `pack create` is idempotent on the smolvm side but
|
# the cache. `pack create` is idempotent on the smolvm side but
|
||||||
@@ -307,6 +312,16 @@ def _launch_vm(
|
|||||||
fails closed if it can't. Smolfile isn't usable here — smolvm 0.8.0
|
fails closed if it can't. Smolfile isn't usable here — smolvm 0.8.0
|
||||||
makes --from and --smolfile mutually exclusive."""
|
makes --from and --smolfile mutually exclusive."""
|
||||||
tsi_cidr = f"{proxy_host}/32"
|
tsi_cidr = f"{proxy_host}/32"
|
||||||
|
# Destroy any leftover machine from a previous run that didn't
|
||||||
|
# clean up (e.g. crash, interrupted teardown).
|
||||||
|
try:
|
||||||
|
_smolvm.machine_stop(plan.machine_name)
|
||||||
|
except _smolvm.SmolvmError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_smolvm.machine_delete(plan.machine_name)
|
||||||
|
except _smolvm.SmolvmError:
|
||||||
|
pass
|
||||||
_smolvm.machine_create(
|
_smolvm.machine_create(
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
from_path=agent_from_path,
|
from_path=agent_from_path,
|
||||||
@@ -374,6 +389,64 @@ def _port_for_label(label: str) -> int:
|
|||||||
raise ValueError(f"unknown sidecar forward label: {label}")
|
raise ValueError(f"unknown sidecar forward label: {label}")
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_sidecar_data(plan: SmolmachinesBottlePlan) -> Path:
|
||||||
|
"""Stage egress + git-gate files into one virtiofs-mountable dir.
|
||||||
|
|
||||||
|
libkrun limits total mounts + port-mappings to 5. With 3 daemon
|
||||||
|
ports the sidecar VM can carry at most 2 mounts (the second is
|
||||||
|
the supervise DB). Egress and git-gate share a single mount:
|
||||||
|
|
||||||
|
<staging>/egress/ → _EGRESS_CONFDIR_IN_VM
|
||||||
|
<staging>/git-gate/ → _GIT_GATE_SCRIPTS_DIR_IN_VM
|
||||||
|
|
||||||
|
The mount is writable so mitmproxy can write combined-trust.pem
|
||||||
|
and cache per-host certs under the egress subdir."""
|
||||||
|
staging = egress_state_dir(plan.slug) / "smolvm-sidecar-data"
|
||||||
|
|
||||||
|
# --- egress subdir ---
|
||||||
|
confdir = staging / "egress"
|
||||||
|
confdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ep = plan.egress_plan
|
||||||
|
shutil.copy2(str(ep.mitmproxy_ca_host_path), str(confdir / "mitmproxy-ca.pem"))
|
||||||
|
if ep.routes:
|
||||||
|
shutil.copy2(str(ep.routes_path), str(confdir / "routes.yaml"))
|
||||||
|
|
||||||
|
# --- git-gate subdir (only when upstreams are configured) ---
|
||||||
|
gp = plan.git_gate_plan
|
||||||
|
if gp.upstreams:
|
||||||
|
scripts_dir = staging / "git-gate"
|
||||||
|
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(str(gp.entrypoint_script), str(scripts_dir / "entrypoint.sh"))
|
||||||
|
shutil.copy2(str(gp.hook_script), str(scripts_dir / "pre-receive"))
|
||||||
|
shutil.copy2(str(gp.access_hook_script), str(scripts_dir / "access-hook"))
|
||||||
|
for name in ("entrypoint.sh", "pre-receive", "access-hook"):
|
||||||
|
(scripts_dir / name).chmod(0o755)
|
||||||
|
|
||||||
|
# Patch credential paths: the rendered entrypoint hardcodes
|
||||||
|
# /git-gate/creds/; rewrite to the in-VM git-gate subdir.
|
||||||
|
ep_path = scripts_dir / "entrypoint.sh"
|
||||||
|
ep_path.write_text(
|
||||||
|
ep_path.read_text().replace(
|
||||||
|
"/git-gate/creds/",
|
||||||
|
f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/creds/",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
creds_dir = scripts_dir / "creds"
|
||||||
|
creds_dir.mkdir(exist_ok=True)
|
||||||
|
for u in gp.upstreams:
|
||||||
|
keypath = Path(expand_tilde(u.identity_file))
|
||||||
|
dest_key = creds_dir / f"{u.name}-key"
|
||||||
|
shutil.copy2(str(keypath), str(dest_key))
|
||||||
|
dest_key.chmod(0o600)
|
||||||
|
if u.known_hosts_file:
|
||||||
|
dest_kh = creds_dir / f"{u.name}-known_hosts"
|
||||||
|
shutil.copy2(str(u.known_hosts_file), str(dest_kh))
|
||||||
|
dest_kh.chmod(0o600)
|
||||||
|
|
||||||
|
return staging
|
||||||
|
|
||||||
|
|
||||||
def _bundle_launch_spec(
|
def _bundle_launch_spec(
|
||||||
plan: SmolmachinesBottlePlan, network: str, proxy_host: str,
|
plan: SmolmachinesBottlePlan, network: str, proxy_host: str,
|
||||||
) -> _bundle.BundleLaunchSpec:
|
) -> _bundle.BundleLaunchSpec:
|
||||||
@@ -392,35 +465,26 @@ def _bundle_launch_spec(
|
|||||||
env: list[str] = []
|
env: list[str] = []
|
||||||
volumes: list[tuple[str, str, bool]] = []
|
volumes: list[tuple[str, str, bool]] = []
|
||||||
|
|
||||||
# --- egress -----------------------------------------------
|
# --- egress + git-gate (single mount) ---------------------
|
||||||
|
# Stage both into one dir and mount it at _SIDECAR_DATA_DIR_IN_VM.
|
||||||
|
# libkrun limits mounts + port-mappings to 5; with 3 daemon ports
|
||||||
|
# we can carry at most 2 mounts (this one + supervise DB).
|
||||||
|
# Writable so egress_entrypoint.sh can write combined-trust.pem
|
||||||
|
# and mitmproxy can create its per-host cert cache.
|
||||||
ep = plan.egress_plan
|
ep = plan.egress_plan
|
||||||
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
|
gp = plan.git_gate_plan
|
||||||
if ep.routes:
|
staging = _stage_sidecar_data(plan)
|
||||||
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
volumes.append((str(staging), _SIDECAR_DATA_DIR_IN_VM, False))
|
||||||
|
# Tell the egress entrypoint where to find its CA + routes.
|
||||||
|
env.append(f"EGRESS_CONFDIR={_EGRESS_CONFDIR_IN_VM}")
|
||||||
|
# Always set EGRESS_ROUTES so the addon reads from the confdir path
|
||||||
|
# even when no routes were configured at launch (apply_routes_change
|
||||||
|
# writes here and a SIGHUP causes the addon to pick them up).
|
||||||
|
env.append(f"EGRESS_ROUTES={_EGRESS_CONFDIR_IN_VM}/routes.yaml")
|
||||||
env.extend(egress_sidecar_env_entries(ep))
|
env.extend(egress_sidecar_env_entries(ep))
|
||||||
|
|
||||||
# --- git-gate ---------------------------------------------
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if gp.upstreams:
|
if gp.upstreams:
|
||||||
daemons += ["git-gate", "git-http"]
|
daemons += ["git-gate", "git-http"]
|
||||||
volumes += [
|
|
||||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
|
|
||||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
|
|
||||||
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, True),
|
|
||||||
]
|
|
||||||
for u in gp.upstreams:
|
|
||||||
keypath = expand_tilde(u.identity_file)
|
|
||||||
volumes.append((
|
|
||||||
keypath,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
if u.known_hosts_file:
|
|
||||||
volumes.append((
|
|
||||||
str(u.known_hosts_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
|
|
||||||
# --- supervise --------------------------------------------
|
# --- supervise --------------------------------------------
|
||||||
sp = plan.supervise_plan
|
sp = plan.supervise_plan
|
||||||
@@ -431,7 +495,13 @@ def _bundle_launch_spec(
|
|||||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False))
|
# virtiofs requires directory mount — mount the DB's parent
|
||||||
|
# dir so bot-bottle.db lands at the right in-VM path.
|
||||||
|
volumes.append((
|
||||||
|
str(sp.db_path.parent),
|
||||||
|
str(Path(DB_PATH_IN_CONTAINER).parent),
|
||||||
|
False,
|
||||||
|
))
|
||||||
|
|
||||||
# Container ports the agent reaches from the smolvm guest —
|
# Container ports the agent reaches from the smolvm guest —
|
||||||
# published on `proxy_host` so the TSI allowlist and the docker
|
# published on `proxy_host` so the TSI allowlist and the docker
|
||||||
|
|||||||
@@ -135,6 +135,16 @@ def start_bundle_vm(
|
|||||||
elif entry in effective_host_env:
|
elif entry in effective_host_env:
|
||||||
env[entry] = effective_host_env[entry]
|
env[entry] = effective_host_env[entry]
|
||||||
name = bundle_machine_name(spec.slug)
|
name = bundle_machine_name(spec.slug)
|
||||||
|
# Destroy any leftover machine from a previous run that didn't
|
||||||
|
# clean up (e.g. crash, interrupted teardown).
|
||||||
|
try:
|
||||||
|
_smolvm.machine_stop(name)
|
||||||
|
except _smolvm.SmolvmError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_smolvm.machine_delete(name)
|
||||||
|
except _smolvm.SmolvmError:
|
||||||
|
pass
|
||||||
_smolvm.machine_create(
|
_smolvm.machine_create(
|
||||||
name,
|
name,
|
||||||
from_path=from_path,
|
from_path=from_path,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ set -e
|
|||||||
# flag mitmdump would generate a fresh CA on the wrong path and
|
# flag mitmdump would generate a fresh CA on the wrong path and
|
||||||
# the agent's installed trust anchor would no longer match the
|
# the agent's installed trust anchor would no longer match the
|
||||||
# bumped leaf certs.
|
# bumped leaf certs.
|
||||||
CONFDIR=/home/mitmproxy/.mitmproxy
|
CONFDIR="${EGRESS_CONFDIR:-/home/mitmproxy/.mitmproxy}"
|
||||||
CONFDIR_FLAG="--set confdir=$CONFDIR"
|
CONFDIR_FLAG="--set confdir=$CONFDIR"
|
||||||
|
|
||||||
MODE="--mode regular@9099"
|
MODE="--mode regular@9099"
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
# Landscape: containerized Claude Code agent tools
|
# Landscape: containerized AI coding agent tools
|
||||||
|
|
||||||
Research into whether bot-bottle is redundant with existing projects, and
|
Research into whether bot-bottle is redundant with existing projects, and
|
||||||
whether it's worth publishing.
|
whether it's worth publishing.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
The "Claude Code in Docker" space is active but not saturated. bot-bottle
|
The "AI coding agents in isolated sandboxes" space is active but not saturated.
|
||||||
occupies a distinct position: no surveyed project combines all five of its
|
bot-bottle occupies a distinct position: no surveyed project combines all five
|
||||||
defining features. Publishing is likely worthwhile, with the main risk being
|
of its defining features. Publishing is likely worthwhile, with the main risk
|
||||||
claudebox expanding to absorb the same niche.
|
being claudebox expanding to absorb the same niche.
|
||||||
|
|
||||||
|
**Updated 2026-07-09:** bot-bottle now supports three isolation backends
|
||||||
|
(Docker, Apple `container`, smolmachines/libkrun microVMs) and three built-in
|
||||||
|
agent providers (Claude Code, OpenAI Codex, Pi) with an open plugin system for
|
||||||
|
arbitrary providers. This meaningfully strengthens the differentiation against
|
||||||
|
all surveyed competitors.
|
||||||
|
|
||||||
## Closest competitor: claudebox
|
## Closest competitor: claudebox
|
||||||
|
|
||||||
@@ -43,28 +49,78 @@ manifest merge.
|
|||||||
Still marked early-development.
|
Still marked early-development.
|
||||||
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
|
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
|
||||||
runtimes; fundamentally different architecture.
|
runtimes; fundamentally different architecture.
|
||||||
|
- **superhq.ai / SuperHQ** (v0.4.4, April 2026) — macOS desktop app (Rust/GPUI)
|
||||||
|
that runs Claude Code, Codex, and Pi inside microVMs via Apple's
|
||||||
|
Virtualization.framework (their own shuru-sdk / libkrun). Auth gateway
|
||||||
|
injects API keys on the wire so the sandbox never sees them; tmpfs overlay
|
||||||
|
stages agent writes for diff-and-accept review; mobile remote access via
|
||||||
|
remote.superhq.ai. Early alpha, free on launch, Apple Silicon only.
|
||||||
|
|
||||||
|
Overlap: both projects cover agent isolation, credential proxying, and
|
||||||
|
multi-provider support (Claude Code / Codex / Pi). Differences: SuperHQ is a
|
||||||
|
GUI desktop app with no manifest layer; bot-bottle is a CLI fleet manager with
|
||||||
|
named agents, skills injection, per-agent system prompts, and cross-platform
|
||||||
|
backends (Docker, Apple `container`, smolmachines). SuperHQ's microVM
|
||||||
|
isolation story is now partially matched by bot-bottle's `macos_container` and
|
||||||
|
smolmachines backends. Worth watching — it targets the same security-minded
|
||||||
|
power-user audience and moves fast.
|
||||||
|
|
||||||
|
**Known gap in SuperHQ (user-requested, as of 2026-07-09):** A named user
|
||||||
|
(Brian Cheong, Founder, Dunialabs.io) explicitly called out the absence of
|
||||||
|
per-run audit logging: tool calls and network egress. Bot-bottle covers both:
|
||||||
|
network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state
|
||||||
|
is persisted to SQLite.
|
||||||
|
|
||||||
## What no found project does
|
## What no found project does
|
||||||
|
|
||||||
None combine:
|
None combine:
|
||||||
1. Named-agent JSON manifest with per-agent env resolution (prompt / host-forward / literal)
|
1. Named-agent manifest with per-agent env resolution (prompt / host-forward / literal), supporting multiple providers (Claude Code, Codex, Pi, arbitrary plugins)
|
||||||
2. Claude Code skills directory injection
|
2. Skills directory injection
|
||||||
3. Per-agent system prompts
|
3. Per-agent system prompts
|
||||||
4. SSH-agent key forwarding without copying private keys into the container
|
4. SSH-agent key forwarding without copying private keys into the container
|
||||||
5. Home + project manifest merge
|
5. Home + project manifest merge
|
||||||
|
6. Pluggable isolation backends: Docker (Linux/macOS), Apple `container` (macOS microVMs), smolmachines/libkrun microVMs
|
||||||
|
7. Per-run audit log: network egress via pipelock/mitmproxy + op-log persisted to SQLite
|
||||||
|
|
||||||
|
**In-flight directions (not yet shipped):**
|
||||||
|
|
||||||
|
- **Forge-native dispatch (issue #317):** Gitea webhook → orchestrator spins up a bottle
|
||||||
|
with the issue body as prompt → agent works → bottle freezes awaiting review comment →
|
||||||
|
rehydrates on comment → tears down on PR close. The issue-to-PR lifecycle concept is not
|
||||||
|
novel (Devin, Copilot Workspace, SWE-agent all do this as cloud services); what's
|
||||||
|
distinct is doing it self-hosted, manifest-driven, inside bot-bottle's isolation
|
||||||
|
primitives.
|
||||||
|
- **Paid web control plane (issue #327):** Browser-based multi-host agent launch and
|
||||||
|
monitoring; account-scoped bottle and agent definitions; secret custody (encrypted at
|
||||||
|
rest, injected into the sidecar at launch, never exposed to the agent or returned by any
|
||||||
|
read API). Monetization model: OSS runtime free, control plane paid — a standard split
|
||||||
|
(HashiCorp, Grafana) applied to a self-hosted agent sandbox. The principled secret
|
||||||
|
custody model (agent never sees real credentials, even via printenv) is more rigorous
|
||||||
|
than most surveyed tools but not unprecedented.
|
||||||
|
|
||||||
## Publishing verdict
|
## Publishing verdict
|
||||||
|
|
||||||
Worth publishing. Differentiators that matter to the target audience (power
|
Worth publishing. Differentiators that matter to the target audience (power
|
||||||
users running parallel Claude Code sessions with distinct personas/tooling):
|
users running parallel AI coding agent sessions with distinct personas/tooling):
|
||||||
|
|
||||||
- The Python-stdlib-first, low-dependency design — competitors are npm-based or
|
- The Python-stdlib-first, low-dependency design — competitors are npm-based,
|
||||||
Kubernetes-native.
|
Rust/GUI, or Kubernetes-native.
|
||||||
- Named agents with distinct skills and system prompts, not just language profiles.
|
- Named agents with distinct skills and system prompts, not just language profiles.
|
||||||
|
- Multi-backend isolation: Docker, Apple `container` microVMs, and
|
||||||
|
smolmachines/libkrun — single manifest works across all three.
|
||||||
|
- Multi-provider: Claude Code, Codex, Pi, plus an open plugin system for
|
||||||
|
arbitrary providers.
|
||||||
- SSH forwarding without key copying.
|
- SSH forwarding without key copying.
|
||||||
|
- Per-run audit log (tool calls + network egress) — an explicitly requested gap
|
||||||
|
in SuperHQ as of 2026-07-09.
|
||||||
|
- Forge-native dispatch and a paid control plane (in flight) bring bot-bottle
|
||||||
|
into the same product category as cloud services like Devin and Copilot
|
||||||
|
Workspace — but self-hosted, with stronger isolation guarantees and a
|
||||||
|
manifest-driven fleet model those services don't have.
|
||||||
|
|
||||||
Main risk: claudebox adds manifest/agent config. The space is moving fast
|
Main risk: claudebox adds manifest/agent config; SuperHQ is moving fast on the
|
||||||
enough that publishing sooner is better if establishing prior art matters.
|
GUI / microVM side. The space is moving fast enough that publishing sooner is
|
||||||
|
better if establishing prior art matters.
|
||||||
|
|
||||||
Discovery will be slow without active promotion; an Anthropic Discord post or
|
Discovery will be slow without active promotion; an Anthropic Discord post or
|
||||||
HN "Show HN" would do most of the work.
|
HN "Show HN" would do most of the work.
|
||||||
@@ -73,4 +129,4 @@ HN "Show HN" would do most of the work.
|
|||||||
|
|
||||||
- GitHub search cannot surface private or very new repos comprehensively.
|
- GitHub search cannot surface private or very new repos comprehensively.
|
||||||
- Counts (stars, forks) were not confirmed for every project.
|
- Counts (stars, forks) were not confirmed for every project.
|
||||||
- Research conducted 2026-05-07; the space moves fast.
|
- Initial research conducted 2026-05-07; SuperHQ entry added 2026-07-09; the space moves fast.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class TestFetchCurrentRoutes(unittest.TestCase):
|
|||||||
self.assertEqual("routes", egress_apply.fetch_current_routes("dev-abc"))
|
self.assertEqual("routes", egress_apply.fetch_current_routes("dev-abc"))
|
||||||
exec_.assert_called_once_with(
|
exec_.assert_called_once_with(
|
||||||
"bot-bottle-sidecars-dev-abc",
|
"bot-bottle-sidecars-dev-abc",
|
||||||
["cat", "/etc/egress/routes.yaml"],
|
["cat", "/bot-bottle-data/egress/routes.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_read_failure_raises_apply_error(self):
|
def test_read_failure_raises_apply_error(self):
|
||||||
|
|||||||
@@ -404,6 +404,10 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.smolmachines.launch._stage_sidecar_data",
|
||||||
|
return_value=Path("/tmp/smolvm-sidecar-data"),
|
||||||
|
):
|
||||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -416,6 +420,10 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
def test_canary_env_registered_as_sensitive_in_bundle(self):
|
def test_canary_env_registered_as_sensitive_in_bundle(self):
|
||||||
plan = _plan(canary=True)
|
plan = _plan(canary=True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.smolmachines.launch._stage_sidecar_data",
|
||||||
|
return_value=Path("/tmp/smolvm-sidecar-data"),
|
||||||
|
):
|
||||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||||
|
|
||||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", spec.environment)
|
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", spec.environment)
|
||||||
@@ -427,10 +435,16 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
def test_supervise_adds_daemon_volume_and_env(self):
|
def test_supervise_adds_daemon_volume_and_env(self):
|
||||||
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
|
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
|
||||||
plan = _plan(supervise=True)
|
plan = _plan(supervise=True)
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.smolmachines.launch._stage_sidecar_data",
|
||||||
|
return_value=Path("/tmp/smolvm-sidecar-data"),
|
||||||
|
):
|
||||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||||
self.assertIn("supervise", spec.daemons_csv)
|
self.assertIn("supervise", spec.daemons_csv)
|
||||||
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
|
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
|
||||||
self.assertIn(("/tmp/bot-bottle.db", DB_PATH_IN_CONTAINER, False), spec.volumes)
|
# virtiofs requires directory mounts; the DB's parent dir is
|
||||||
|
# mounted so bot-bottle.db lands at the right in-VM path.
|
||||||
|
self.assertIn(("/tmp", str(Path(DB_PATH_IN_CONTAINER).parent), False), spec.volumes)
|
||||||
|
|
||||||
def test_canary_env_visible_to_smolvm_guest(self):
|
def test_canary_env_visible_to_smolvm_guest(self):
|
||||||
plan = _plan(canary=True)
|
plan = _plan(canary=True)
|
||||||
@@ -555,6 +569,9 @@ class TestLaunchResourceWiring(unittest.TestCase):
|
|||||||
"bot_bottle.backend.smolmachines.launch._bundle.start_bundle_vm",
|
"bot_bottle.backend.smolmachines.launch._bundle.start_bundle_vm",
|
||||||
return_value=raw_launch,
|
return_value=raw_launch,
|
||||||
) as start_vm, patch(
|
) as start_vm, patch(
|
||||||
|
"bot_bottle.backend.smolmachines.launch._stage_sidecar_data",
|
||||||
|
return_value=Path("/tmp/smolvm-sidecar-data"),
|
||||||
|
), patch(
|
||||||
"bot_bottle.backend.smolmachines.launch._forward.start_forwarder",
|
"bot_bottle.backend.smolmachines.launch._forward.start_forwarder",
|
||||||
return_value=handle,
|
return_value=handle,
|
||||||
) as start_forwarder:
|
) as start_forwarder:
|
||||||
|
|||||||
Reference in New Issue
Block a user