Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 551324f8cd |
+26
-95
@@ -4,15 +4,16 @@
|
|||||||
# dependencies are required to execute it. Tests are split by directory:
|
# dependencies are required to execute it. Tests are split by directory:
|
||||||
#
|
#
|
||||||
# tests/unit/ — pure unit tests; always run
|
# tests/unit/ — pure unit tests; always run
|
||||||
# tests/integration/ — need a reachable backend; skip cleanly when
|
# tests/integration/ — need a reachable Docker daemon; skip cleanly
|
||||||
# the backend isn't available on the runner
|
# (via tests/_docker.py:skip_unless_docker) when
|
||||||
|
# Docker isn't available on the runner
|
||||||
# tests/canaries/ — upstream regression canaries; run on a separate
|
# tests/canaries/ — upstream regression canaries; run on a separate
|
||||||
# schedule (see canaries.yml), not here
|
# schedule (see canaries.yml), not here
|
||||||
#
|
#
|
||||||
# Integration tests run once per backend in separate jobs. Each job sets
|
# This workflow assumes the Gitea Actions runner exposes the host Docker
|
||||||
# BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend.
|
# socket to the job container so `docker` commands inside the job can
|
||||||
# Backends that aren't available on the runner fail the preflight step
|
# reach the daemon. If that's not yet configured on the runner the
|
||||||
# rather than silently skipping inside the test output.
|
# integration tests will skip rather than fail.
|
||||||
|
|
||||||
name: test
|
name: test
|
||||||
|
|
||||||
@@ -22,16 +23,9 @@ on:
|
|||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- '**.py'
|
||||||
- '.gitea/workflows/**.yml'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'README.md'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- '**.py'
|
||||||
- '.gitea/workflows/**.yml'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'README.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
@@ -54,7 +48,7 @@ jobs:
|
|||||||
- name: Report unit coverage
|
- name: Report unit coverage
|
||||||
run: python3 -m coverage report -m
|
run: python3 -m coverage report -m
|
||||||
|
|
||||||
integration-docker:
|
integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -71,96 +65,33 @@ jobs:
|
|||||||
echo "docker not on PATH — integration tests will skip"
|
echo "docker not on PATH — integration tests will skip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run integration tests (docker)
|
- name: Run integration tests
|
||||||
env:
|
|
||||||
BOT_BOTTLE_BACKEND: docker
|
|
||||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
run: python3 -m unittest discover -t . -s tests/integration -v
|
||||||
|
|
||||||
# Integration tests against the Firecracker backend. Runs on a self-hosted
|
# Combined unit+integration coverage report (informational). See
|
||||||
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
|
# docs/decisions/0004-coverage-policy.md.
|
||||||
#
|
#
|
||||||
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
|
# The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the
|
||||||
# PRs don't execute untrusted code on the privileged runner.
|
# Firecracker backend's VM/SSH orchestration is covered by the integration
|
||||||
#
|
# suite, which needs /dev/kvm + the provisioned TAP/nft pool — a
|
||||||
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
# container-based runner skips it and those lines read uncovered, so the
|
||||||
# `firecracker` on PATH, `/dev/kvm` accessible, Docker, cached kernel +
|
# gate can't pass here. Re-enabling it on a self-hosted KVM runner is
|
||||||
# static dropbear, and the pool as a persistent systemd unit.
|
# tracked separately (see PRD 0069 / #348 and the ci-runner branch).
|
||||||
integration-firecracker:
|
|
||||||
runs-on: [self-hosted, kvm]
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' ||
|
|
||||||
github.event_name == 'workflow_dispatch' ||
|
|
||||||
(github.event_name == 'pull_request' &&
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository)
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Preflight — Firecracker host is ready
|
|
||||||
run: |
|
|
||||||
command -v firecracker >/dev/null || {
|
|
||||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
|
||||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
|
||||||
# `backend status` exits non-zero unless the TAP pool is up + no
|
|
||||||
# range overlap; it prints the exact `backend setup` fix.
|
|
||||||
python3 cli.py backend status --backend=firecracker
|
|
||||||
|
|
||||||
# No dev-requirements install: the integration suite runs on stdlib
|
|
||||||
# `unittest` (pylint/pyright are lint.yml's concern, not this job's),
|
|
||||||
# and the self-hosted runner's Nix python env has no `pip` module
|
|
||||||
# (`python3 -m pip` → "No module named pip"). Nothing to install.
|
|
||||||
- name: Run integration tests (firecracker)
|
|
||||||
env:
|
|
||||||
BOT_BOTTLE_BACKEND: firecracker
|
|
||||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
|
||||||
|
|
||||||
# Combined unit+integration coverage + the diff-coverage gate (the hard
|
|
||||||
# gate: new/changed lines >= 90%). See docs/decisions/0004-coverage-policy.md.
|
|
||||||
#
|
|
||||||
# This runs on a self-hosted KVM runner (label `kvm`), NOT ubuntu-latest,
|
|
||||||
# because the Firecracker backend's subprocess/VM orchestration
|
|
||||||
# (launch/boot/SSH/isolation-probe) is covered by the integration suite,
|
|
||||||
# and that suite needs `/dev/kvm` + the provisioned TAP/nft pool — which a
|
|
||||||
# container-based runner doesn't have. On such a runner the firecracker
|
|
||||||
# integration test skips and its ~230 orchestration lines read as
|
|
||||||
# uncovered, so the gate can't pass there.
|
|
||||||
#
|
|
||||||
# Restricted to the same events as integration-firecracker (same-repo PRs,
|
|
||||||
# push, workflow_dispatch) for the same security reason.
|
|
||||||
#
|
|
||||||
# See #414 for the planned follow-up: artifact-based coverage combination
|
|
||||||
# (run tests once in their respective jobs, combine .coverage files here).
|
|
||||||
coverage:
|
coverage:
|
||||||
timeout-minutes: 15
|
runs-on: ubuntu-latest
|
||||||
runs-on: [self-hosted, kvm]
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' ||
|
|
||||||
github.event_name == 'workflow_dispatch' ||
|
|
||||||
(github.event_name == 'pull_request' &&
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository)
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Preflight — Firecracker host is ready
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
run: |
|
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||||
command -v firecracker >/dev/null || {
|
# lands in one interpreter, `python3` resolves to another). Install
|
||||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
# straight into the ephemeral job container's system Python —
|
||||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
# --break-system-packages is safe because the container is disposable.
|
||||||
# `backend status` exits non-zero unless the TAP pool is up + no
|
- name: Install dev requirements
|
||||||
# range overlap; it prints the exact `backend setup` fix.
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
python3 cli.py backend status --backend=firecracker
|
|
||||||
|
|
||||||
# No dev-requirements install: `coverage` is already provided by the
|
- name: Combined coverage report (unit + integration)
|
||||||
# self-hosted runner's Nix python env, and that env has no `pip`
|
|
||||||
# module to install into anyway. `scripts/coverage.sh` +
|
|
||||||
# `diff_coverage.py` need only `coverage` (not pylint/pyright).
|
|
||||||
- name: Combined coverage (unit + integration, incl. firecracker)
|
|
||||||
run: PYTHON=python3 bash scripts/coverage.sh critical
|
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||||
|
|
||||||
- name: Diff-coverage gate (changed lines >= 90%)
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
|
||||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: tracker-policy-pr
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, edited, reopened, synchronize, labeled, unlabeled]
|
types: [opened, edited, reopened, synchronized, labeled, unlabeled]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-pr:
|
check-pr:
|
||||||
|
|||||||
@@ -90,8 +90,6 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
|||||||
|
|
||||||
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
|
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
|
||||||
|
|
||||||
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, Docker, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. The unit/lint jobs still run on `ubuntu-latest`.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
|||||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||||
|
|
||||||
|
# Host that egress injects the host Claude bearer on when Claude
|
||||||
|
# forward_host_credentials is enabled.
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
|
||||||
PromptMode = Literal[
|
PromptMode = Literal[
|
||||||
"append_file",
|
"append_file",
|
||||||
"read_prompt_file",
|
"read_prompt_file",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class AuditStore(DbStore):
|
|||||||
super().__init__(db_path or host_db_path(), migrations)
|
super().__init__(db_path or host_db_path(), migrations)
|
||||||
|
|
||||||
def write_audit_entry(self, entry: AuditEntry) -> Path:
|
def write_audit_entry(self, entry: AuditEntry) -> Path:
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO supervise_audit_entries (
|
INSERT INTO supervise_audit_entries (
|
||||||
@@ -66,7 +66,7 @@ class AuditStore(DbStore):
|
|||||||
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
|
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
|
||||||
if not self.db_path.is_file():
|
if not self.db_path.is_file():
|
||||||
return []
|
return []
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM supervise_audit_entries
|
SELECT * FROM supervise_audit_entries
|
||||||
|
|||||||
@@ -27,14 +27,10 @@ def _docker_on_path() -> bool:
|
|||||||
def _daemon_reachable() -> bool:
|
def _daemon_reachable() -> bool:
|
||||||
if not _docker_on_path():
|
if not _docker_on_path():
|
||||||
return False
|
return False
|
||||||
try:
|
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["docker", "info"],
|
["docker", "info"],
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
check=False, timeout=5,
|
|
||||||
).returncode == 0
|
).returncode == 0
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _print_install_pointer() -> None:
|
def _print_install_pointer() -> None:
|
||||||
|
|||||||
@@ -38,13 +38,6 @@ COMMANDS = {
|
|||||||
"supervise": cmd_supervise,
|
"supervise": cmd_supervise,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Commands that manage host prerequisites (or are otherwise store-free) and
|
|
||||||
# must run before — or without — a migrated DB. `backend` provisions/probes
|
|
||||||
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
|
|
||||||
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
|
||||||
# isn't a TTY and the migration prompt can't be answered.
|
|
||||||
NO_MIGRATION_COMMANDS = frozenset({"backend"})
|
|
||||||
|
|
||||||
|
|
||||||
def usage() -> None:
|
def usage() -> None:
|
||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
@@ -87,7 +80,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
usage()
|
usage()
|
||||||
die(f"unknown command: {command}")
|
die(f"unknown command: {command}")
|
||||||
mgr = StoreManager.instance()
|
mgr = StoreManager.instance()
|
||||||
if command not in NO_MIGRATION_COMMANDS and not mgr.is_migrated():
|
if not mgr.is_migrated():
|
||||||
sys.stderr.write("bot-bottle: database schema is out of date\n")
|
sys.stderr.write("bot-bottle: database schema is out of date\n")
|
||||||
sys.stderr.write("Migrate now? [y/N] ")
|
sys.stderr.write("Migrate now? [y/N] ")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ from ...agent_provider import (
|
|||||||
provider_startup_args,
|
provider_startup_args,
|
||||||
)
|
)
|
||||||
from ...backend.docker import util as docker_mod
|
from ...backend.docker import util as docker_mod
|
||||||
from ...egress import EgressRoute
|
from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from .claude_auth import claude_host_access_token
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -118,7 +119,6 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
color: str = "",
|
color: str = "",
|
||||||
provider_settings: dict[str, object] | None = None,
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
del forward_host_credentials, host_env
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
resolved_guest_env = dict(guest_env or {})
|
||||||
startup_args = provider_startup_args(provider_settings)
|
startup_args = provider_startup_args(provider_settings)
|
||||||
guest_home = self.guest_home
|
guest_home = self.guest_home
|
||||||
@@ -180,13 +180,24 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
claude_settings,
|
claude_settings,
|
||||||
f"{guest_home}/.claude/settings.json",
|
f"{guest_home}/.claude/settings.json",
|
||||||
))
|
))
|
||||||
|
provisioned_env: dict[str, str] = {}
|
||||||
|
if forward_host_credentials:
|
||||||
|
_host_env = host_env or dict(os.environ)
|
||||||
|
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
|
||||||
|
claude_host_access_token(_host_env)
|
||||||
|
)
|
||||||
|
|
||||||
|
cred_token_ref = (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
|
||||||
|
else auth_token
|
||||||
|
)
|
||||||
egress_routes = (EgressRoute(
|
egress_routes = (EgressRoute(
|
||||||
host="api.anthropic.com",
|
host="api.anthropic.com",
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
|
||||||
token_ref=auth_token,
|
token_ref=cred_token_ref,
|
||||||
),)
|
),)
|
||||||
hidden_env_names: frozenset[str] = frozenset()
|
hidden_env_names: frozenset[str] = frozenset()
|
||||||
if auth_token:
|
if auth_token or forward_host_credentials:
|
||||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||||
|
|
||||||
@@ -208,6 +219,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
egress_routes=egress_routes,
|
egress_routes=egress_routes,
|
||||||
hidden_env_names=hidden_env_names,
|
hidden_env_names=hidden_env_names,
|
||||||
|
provisioned_env=provisioned_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Host Claude auth helpers.
|
||||||
|
|
||||||
|
Reads the host's Claude Code credentials and returns only the access
|
||||||
|
token needed by egress. Does not expose refresh tokens or raw payloads.
|
||||||
|
|
||||||
|
Credential storage by platform:
|
||||||
|
Linux — ~/.claude/.credentials.json
|
||||||
|
macOS — macOS Keychain, service "Claude Code-credentials"
|
||||||
|
(file path is tried first; Keychain is the fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die
|
||||||
|
|
||||||
|
|
||||||
|
_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
||||||
|
env = os.environ if host_env is None else host_env
|
||||||
|
home = env.get("HOME")
|
||||||
|
if home:
|
||||||
|
return Path(home) / ".claude" / ".credentials.json"
|
||||||
|
return Path.home() / ".claude" / ".credentials.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_keychain() -> dict[str, object] | None:
|
||||||
|
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = json.loads(result.stdout.strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def claude_host_access_token(
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
path = claude_auth_path(host_env)
|
||||||
|
raw: dict[str, object] | None = None
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
die(f"claude host credentials: {path} must contain a JSON object")
|
||||||
|
else:
|
||||||
|
raw = _read_keychain()
|
||||||
|
if raw is None:
|
||||||
|
die(
|
||||||
|
f"claude host credentials: auth file missing at {path} and "
|
||||||
|
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth = raw.get("claudeAiOauth")
|
||||||
|
if not isinstance(oauth, dict):
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth is missing from credentials. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = oauth.get("accessToken")
|
||||||
|
if not isinstance(access_token, str) or not access_token:
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
# expiresAt is in milliseconds
|
||||||
|
expires_at = oauth.get("expiresAt")
|
||||||
|
if isinstance(expires_at, (int, float)):
|
||||||
|
check_now = now or datetime.now(timezone.utc)
|
||||||
|
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
|
||||||
|
if exp_dt <= check_now:
|
||||||
|
die(
|
||||||
|
"claude host credentials: host Claude access token is expired. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"claude_auth_path",
|
||||||
|
"claude_host_access_token",
|
||||||
|
]
|
||||||
+2
-12
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -29,21 +28,12 @@ class DbStore:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _connection(self):
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn:
|
|
||||||
yield conn
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def is_migrated(self) -> bool:
|
def is_migrated(self) -> bool:
|
||||||
"""Return True if the DB is fully up-to-date, False if migration is needed."""
|
"""Return True if the DB is fully up-to-date, False if migration is needed."""
|
||||||
if not self.db_path.exists():
|
if not self.db_path.exists():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT version FROM schema_versions WHERE module = ?",
|
"SELECT version FROM schema_versions WHERE module = ?",
|
||||||
(self._migrations.schema_key,),
|
(self._migrations.schema_key,),
|
||||||
@@ -55,7 +45,7 @@ class DbStore:
|
|||||||
|
|
||||||
def migrate(self) -> None:
|
def migrate(self) -> None:
|
||||||
"""Apply any pending migrations and set permissions on the DB file."""
|
"""Apply any pending migrations and set permissions on the DB file."""
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
self._migrations.apply(conn)
|
self._migrations.apply(conn)
|
||||||
self._chmod()
|
self._chmod()
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
from .manifest import ManifestBottle
|
from .manifest import ManifestBottle
|
||||||
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
|
||||||
|
|
||||||
EGRESS_HOSTNAME = "egress"
|
EGRESS_HOSTNAME = "egress"
|
||||||
|
|
||||||
@@ -400,6 +401,7 @@ class Egress(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
"EGRESS_ROUTES_FILENAME",
|
"EGRESS_ROUTES_FILENAME",
|
||||||
|
|||||||
@@ -419,24 +419,18 @@ PY
|
|||||||
while IFS=' ' read -r old new ref; do
|
while IFS=' ' read -r old new ref; do
|
||||||
[ -z "$ref" ] && continue
|
[ -z "$ref" ] && continue
|
||||||
[ "$new" = "$zero" ] && continue
|
[ "$new" = "$zero" ] && continue
|
||||||
# Scan only the commits this push introduces — those reachable from
|
if [ "$old" = "$zero" ]; then
|
||||||
# $new but not from any ref the gate already has. Everything already
|
# New ref: scan only the commits this push introduces — those
|
||||||
# on the gate arrived via upstream mirror-fetch or a previously
|
# reachable from $new but not from any ref the gate already has.
|
||||||
# gitleaks-scanned push, so it's already-upstream or already-scanned;
|
# Everything already on the gate arrived via upstream mirror-fetch
|
||||||
# re-scanning it only resurfaces historical fixture findings.
|
# or a previously gitleaks-scanned push, so it's already-upstream
|
||||||
#
|
# or already-scanned; re-scanning it (the old `$new` full-ancestry
|
||||||
# Applies to both new refs and updates. The old existing-branch range
|
# range) only resurfaces historical findings and blocks every new
|
||||||
# `$old..$new` walks commits reachable from the new tip but not the
|
# branch. See PRD 0028 / issue #106.
|
||||||
# *old branch tip*: on a rebase/force-push onto a freshly-advanced
|
|
||||||
# main that pulls in all of main's new history (incl. the deliberate
|
|
||||||
# sandbox-escape gitleaks fixtures), blocking the push. `--not --all`
|
|
||||||
# excludes anything already on the gate regardless of ancestry, so it
|
|
||||||
# is also correct for non-fast-forward pushes (a rebase can skip
|
|
||||||
# commits off the direct path). Security-equivalent per PRD 0028's
|
|
||||||
# analysis: the bare repo's refs come only from trusted upstream
|
|
||||||
# mirror-fetch or gitleaks-gated pushes.
|
|
||||||
# See PRD 0028 (open question) / issues #106, #346.
|
|
||||||
log_opts="$new --not --all"
|
log_opts="$new --not --all"
|
||||||
|
else
|
||||||
|
log_opts="$old..$new"
|
||||||
|
fi
|
||||||
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
|
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
|
||||||
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
|
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
|
||||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ class ManifestAgentProvider:
|
|||||||
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
||||||
so the Claude Code CLI starts.
|
so the Claude Code CLI starts.
|
||||||
|
|
||||||
`forward_host_credentials` forwards the host Codex auth token into
|
`forward_host_credentials` forwards the host provider auth token into
|
||||||
the egress daemon (Codex only).
|
the egress sidecar (Codex and Claude). For Codex this reads
|
||||||
|
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
@@ -92,10 +93,15 @@ class ManifestAgentProvider:
|
|||||||
f"is only supported for built-in templates "
|
f"is only supported for built-in templates "
|
||||||
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
||||||
)
|
)
|
||||||
if forward_host_credentials and template != "codex":
|
if forward_host_credentials and template not in {"codex", "claude"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
"is currently only supported for template 'codex'"
|
"is only supported for templates 'codex' and 'claude'"
|
||||||
|
)
|
||||||
|
if forward_host_credentials and auth_token:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
|
"and auth_token both set; use one or the other"
|
||||||
)
|
)
|
||||||
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class RegistryStore(DbStore):
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
policy=policy,
|
policy=policy,
|
||||||
)
|
)
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM orchestrator_bottles "
|
"DELETE FROM orchestrator_bottles "
|
||||||
"WHERE source_ip = ? AND state = 'active' AND bottle_id != ?",
|
"WHERE source_ip = ? AND state = 'active' AND bottle_id != ?",
|
||||||
@@ -193,7 +193,7 @@ class RegistryStore(DbStore):
|
|||||||
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
"""Update a bottle's policy in place (live reload). Returns True if
|
"""Update a bottle's policy in place (live reload). Returns True if
|
||||||
the bottle exists."""
|
the bottle exists."""
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?",
|
"UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?",
|
||||||
(policy, bottle_id),
|
(policy, bottle_id),
|
||||||
@@ -203,7 +203,7 @@ class RegistryStore(DbStore):
|
|||||||
|
|
||||||
def deregister(self, bottle_id: str) -> bool:
|
def deregister(self, bottle_id: str) -> bool:
|
||||||
"""Remove a bottle. Returns True if a row was deleted."""
|
"""Remove a bottle. Returns True if a row was deleted."""
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
|
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
|
||||||
)
|
)
|
||||||
@@ -211,7 +211,7 @@ class RegistryStore(DbStore):
|
|||||||
|
|
||||||
def get(self, bottle_id: str) -> BottleRecord | None:
|
def get(self, bottle_id: str) -> BottleRecord | None:
|
||||||
"""Return the bottle by id, or None if absent."""
|
"""Return the bottle by id, or None if absent."""
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
|
"SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -219,7 +219,7 @@ class RegistryStore(DbStore):
|
|||||||
|
|
||||||
def all(self) -> list[BottleRecord]:
|
def all(self) -> list[BottleRecord]:
|
||||||
"""Every registered bottle, oldest first."""
|
"""Every registered bottle, oldest first."""
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM orchestrator_bottles ORDER BY created_at"
|
"SELECT * FROM orchestrator_bottles ORDER BY created_at"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -232,7 +232,7 @@ class RegistryStore(DbStore):
|
|||||||
source IP is unspoofable (Firecracker `/31` + nft) and the control
|
source IP is unspoofable (Firecracker `/31` + nft) and the control
|
||||||
plane is reachable only by the trusted gateway; pair with the
|
plane is reachable only by the trusted gateway; pair with the
|
||||||
identity token (`attribute`) elsewhere."""
|
identity token (`attribute`) elsewhere."""
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM orchestrator_bottles "
|
"SELECT * FROM orchestrator_bottles "
|
||||||
"WHERE source_ip = ? AND state = 'active'",
|
"WHERE source_ip = ? AND state = 'active'",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class QueueStore(DbStore):
|
|||||||
super().__init__(resolved, migrations)
|
super().__init__(resolved, migrations)
|
||||||
|
|
||||||
def write_proposal(self, proposal: Proposal) -> Path:
|
def write_proposal(self, proposal: Proposal) -> Path:
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO supervise_proposals (
|
INSERT OR REPLACE INTO supervise_proposals (
|
||||||
@@ -89,7 +89,7 @@ class QueueStore(DbStore):
|
|||||||
return self.db_path
|
return self.db_path
|
||||||
|
|
||||||
def read_proposal(self, proposal_id: str) -> Proposal:
|
def read_proposal(self, proposal_id: str) -> Proposal:
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM supervise_proposals
|
SELECT * FROM supervise_proposals
|
||||||
@@ -104,7 +104,7 @@ class QueueStore(DbStore):
|
|||||||
def list_pending_proposals(self) -> list[Proposal]:
|
def list_pending_proposals(self) -> list[Proposal]:
|
||||||
if not self.db_path.is_file():
|
if not self.db_path.is_file():
|
||||||
return []
|
return []
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT p.* FROM supervise_proposals p
|
SELECT p.* FROM supervise_proposals p
|
||||||
@@ -125,7 +125,7 @@ class QueueStore(DbStore):
|
|||||||
def list_all_pending_proposals(self) -> list[Proposal]:
|
def list_all_pending_proposals(self) -> list[Proposal]:
|
||||||
if not self.db_path.is_file():
|
if not self.db_path.is_file():
|
||||||
return []
|
return []
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT p.* FROM supervise_proposals p
|
SELECT p.* FROM supervise_proposals p
|
||||||
@@ -142,7 +142,7 @@ class QueueStore(DbStore):
|
|||||||
return [self._row_to_proposal(row) for row in rows]
|
return [self._row_to_proposal(row) for row in rows]
|
||||||
|
|
||||||
def write_response(self, response: Response) -> Path:
|
def write_response(self, response: Response) -> Path:
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO supervise_responses (
|
INSERT OR REPLACE INTO supervise_responses (
|
||||||
@@ -161,7 +161,7 @@ class QueueStore(DbStore):
|
|||||||
return self.db_path
|
return self.db_path
|
||||||
|
|
||||||
def read_response(self, proposal_id: str) -> Response:
|
def read_response(self, proposal_id: str) -> Response:
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM supervise_responses
|
SELECT * FROM supervise_responses
|
||||||
@@ -176,7 +176,7 @@ class QueueStore(DbStore):
|
|||||||
def archive_proposal(self, proposal_id: str) -> None:
|
def archive_proposal(self, proposal_id: str) -> None:
|
||||||
if not self.db_path.is_file():
|
if not self.db_path.is_file():
|
||||||
return
|
return
|
||||||
with self._connection() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE supervise_proposals SET archived = 1
|
UPDATE supervise_proposals SET archived = 1
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from .supervise_types import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOLS,
|
TOOLS,
|
||||||
TOOL_CHECK_PROPOSAL,
|
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
@@ -264,7 +263,6 @@ __all__ = [
|
|||||||
"TOOLS",
|
"TOOLS",
|
||||||
"EGRESS_FORWARD_PROXY",
|
"EGRESS_FORWARD_PROXY",
|
||||||
"EGRESS_INTROSPECT_URL",
|
"EGRESS_INTROSPECT_URL",
|
||||||
"TOOL_CHECK_PROPOSAL",
|
|
||||||
"TOOL_EGRESS_ALLOW",
|
"TOOL_EGRESS_ALLOW",
|
||||||
"TOOL_EGRESS_BLOCK",
|
"TOOL_EGRESS_BLOCK",
|
||||||
"TOOL_GITLEAKS_ALLOW",
|
"TOOL_GITLEAKS_ALLOW",
|
||||||
|
|||||||
@@ -2,24 +2,14 @@
|
|||||||
|
|
||||||
Per-bottle MCP server exposing tools the agent calls to propose egress
|
Per-bottle MCP server exposing tools the agent calls to propose egress
|
||||||
config changes when stuck. The tools are `egress-allow`,
|
config changes when stuck. The tools are `egress-allow`,
|
||||||
`egress-block`, `list-egress-routes`, and `check-proposal`.
|
`egress-block`, and `list-egress-routes`.
|
||||||
|
|
||||||
Each queued proposal tool call:
|
Each queued tool call:
|
||||||
|
|
||||||
1. Validates the proposed file syntactically.
|
1. Validates the proposed file syntactically.
|
||||||
2. Writes a Proposal to the host SQLite database.
|
2. Writes a Proposal to the host SQLite database.
|
||||||
3. Blocks polling for a matching Response row, up to a short grace
|
3. Blocks polling for a matching Response row.
|
||||||
window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`, default 30s).
|
4. Returns the operator's `{status, notes}` to the agent.
|
||||||
4. On a decision within the window, returns the operator's
|
|
||||||
`{status, notes}`. On timeout, returns `status: pending` **with the
|
|
||||||
proposal id** and leaves the proposal queued — the flow is
|
|
||||||
non-blocking past the grace window (PRD prd-new / issue #412).
|
|
||||||
|
|
||||||
`check-proposal` is the non-blocking companion: given a `proposal_id`
|
|
||||||
returned by a `pending` response, it reports the current decision
|
|
||||||
(`pending` | `approved` | `modified` | `rejected`) without re-proposing,
|
|
||||||
so an approval made out-of-band (e.g. a web review console) can be resumed
|
|
||||||
without holding an HTTP request open.
|
|
||||||
|
|
||||||
One shared server fronts every bottle (PRD 0070) and attributes each
|
One shared server fronts every bottle (PRD 0070) and attributes each
|
||||||
proposal to the calling bottle by source IP, resolved from the orchestrator
|
proposal to the calling bottle by source IP, resolved from the orchestrator
|
||||||
@@ -32,9 +22,7 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
|||||||
* `initialize` — handshake; returns server info + caps.
|
* `initialize` — handshake; returns server info + caps.
|
||||||
* `notifications/initialized` — ack-only.
|
* `notifications/initialized` — ack-only.
|
||||||
* `tools/list` — returns the tool definitions.
|
* `tools/list` — returns the tool definitions.
|
||||||
* `tools/call` — validates, queues, waits out the grace
|
* `tools/call` — validates, queues, blocks, returns.
|
||||||
window, returns (pending past it); or, for
|
|
||||||
`check-proposal`, a non-blocking status poll.
|
|
||||||
|
|
||||||
Everything else returns JSON-RPC error -32601 (method not found).
|
Everything else returns JSON-RPC error -32601 (method not found).
|
||||||
|
|
||||||
@@ -244,31 +232,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
),
|
),
|
||||||
"inputSchema": _proposal_input_schema(),
|
"inputSchema": _proposal_input_schema(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": _sv.TOOL_CHECK_PROPOSAL,
|
|
||||||
"description": (
|
|
||||||
"Poll a previously queued proposal for the operator's decision "
|
|
||||||
"WITHOUT blocking or re-proposing. Pass the `proposal_id` you "
|
|
||||||
"got back when an `egress-allow`/`egress-block` call returned "
|
|
||||||
"`status: pending`. Returns the current status: `pending` (no "
|
|
||||||
"decision yet — poll again later), `approved`, `modified`, "
|
|
||||||
"`rejected`, or `unknown` (no such queued proposal — wrong id, "
|
|
||||||
"or it was already resolved and read)."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"proposal_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"The proposal id from a `pending` response."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["proposal_id"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -390,7 +353,7 @@ def handle_tools_call(
|
|||||||
deadline=deadline,
|
deadline=deadline,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
|
text = format_pending_response_text(config.response_timeout_seconds)
|
||||||
return {
|
return {
|
||||||
"content": [{"type": "text", "text": text}],
|
"content": [{"type": "text", "text": text}],
|
||||||
"isError": False,
|
"isError": False,
|
||||||
@@ -407,54 +370,6 @@ def handle_tools_call(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def handle_check_proposal(
|
|
||||||
params: dict[str, object],
|
|
||||||
config: ServerConfig,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Non-blocking poll of a queued proposal's decision, by id.
|
|
||||||
|
|
||||||
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
|
|
||||||
reads the queue. Resolution order mirrors the synchronous path's terminal
|
|
||||||
step — a decided proposal is archived here exactly as `handle_tools_call`
|
|
||||||
archives it after `wait_for_response`, so `pending` proposals stay visible
|
|
||||||
to the operator until they're both decided *and* polled."""
|
|
||||||
args_raw = params.get("arguments", {})
|
|
||||||
if not isinstance(args_raw, dict):
|
|
||||||
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
|
|
||||||
proposal_id = args_raw.get("proposal_id")
|
|
||||||
if not isinstance(proposal_id, str) or not proposal_id.strip():
|
|
||||||
raise _RpcClientError(
|
|
||||||
ERR_INVALID_PARAMS,
|
|
||||||
"check-proposal: 'proposal_id' is required and must be a non-empty string",
|
|
||||||
)
|
|
||||||
proposal_id = proposal_id.strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = _sv.read_response(config.bottle_slug, proposal_id)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# No decision yet — distinguish "still queued" from "unknown id".
|
|
||||||
try:
|
|
||||||
_sv.read_proposal(config.bottle_slug, proposal_id)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
|
|
||||||
"isError": True,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
|
|
||||||
"isError": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
_sv.archive_proposal(config.bottle_slug, proposal_id)
|
|
||||||
except OSError as e:
|
|
||||||
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
|
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": format_response_text(response)}],
|
|
||||||
"isError": response.status == _sv.STATUS_REJECTED,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_response_text(response: "_sv.Response") -> str:
|
def format_response_text(response: "_sv.Response") -> str:
|
||||||
"""Pretty-print a Response for the tool's text content. The agent
|
"""Pretty-print a Response for the tool's text content. The agent
|
||||||
reads the text and decides whether to retry / give up / surface."""
|
reads the text and decides whether to retry / give up / surface."""
|
||||||
@@ -467,35 +382,12 @@ def format_response_text(response: "_sv.Response") -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def format_pending_response_text(proposal_id: str, timeout_seconds: float) -> str:
|
def format_pending_response_text(timeout_seconds: float) -> str:
|
||||||
"""Grace-window timeout: the proposal stays queued, and the agent is
|
|
||||||
told the id so it can `check-proposal` instead of re-proposing."""
|
|
||||||
return "\n".join([
|
return "\n".join([
|
||||||
"status: pending",
|
"status: pending",
|
||||||
f"proposal_id: {proposal_id}",
|
|
||||||
(
|
(
|
||||||
f"notes: no operator decision within {timeout_seconds:g}s; the "
|
"notes: operator response timed out after "
|
||||||
"proposal remains queued. Poll it (do not re-propose) by calling "
|
f"{timeout_seconds:g}s; proposal remains queued"
|
||||||
f"`check-proposal` with proposal_id={proposal_id!r}."
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def format_still_pending_text(proposal_id: str) -> str:
|
|
||||||
return "\n".join([
|
|
||||||
"status: pending",
|
|
||||||
f"proposal_id: {proposal_id}",
|
|
||||||
"notes: still queued; no operator decision yet. Call `check-proposal` again later.",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def format_unknown_proposal_text(proposal_id: str) -> str:
|
|
||||||
return "\n".join([
|
|
||||||
"status: unknown",
|
|
||||||
f"proposal_id: {proposal_id}",
|
|
||||||
(
|
|
||||||
"notes: no queued proposal with this id for this bottle — the id "
|
|
||||||
"may be wrong, or the proposal was already resolved and read."
|
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -590,11 +482,6 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
# — silently dropping base routes like api.anthropic.com on approval.
|
# — silently dropping base routes like api.anthropic.com on approval.
|
||||||
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
|
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||||
return self._resolved_routes_payload()
|
return self._resolved_routes_payload()
|
||||||
# `check-proposal` is a non-blocking read of the calling bottle's
|
|
||||||
# own queue — attributed by source IP like a proposal, but it
|
|
||||||
# never queues or blocks.
|
|
||||||
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
|
|
||||||
return handle_check_proposal(req.params, self._attributed_config(config))
|
|
||||||
# Attribute the proposal to the source-IP-resolved bottle, so the one
|
# Attribute the proposal to the source-IP-resolved bottle, so the one
|
||||||
# shared server queues each bottle's proposal under its own slug.
|
# shared server queues each bottle's proposal under its own slug.
|
||||||
return handle_tools_call(req.params, self._attributed_config(config))
|
return handle_tools_call(req.params, self._attributed_config(config))
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ TOOL_EGRESS_ALLOW = "egress-allow"
|
|||||||
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||||
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
||||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||||
# Read-only agent tool: poll a queued proposal for the operator's decision
|
|
||||||
# without blocking or re-proposing. It never becomes a `Proposal.tool` (no
|
|
||||||
# queue record is created for it), so it is intentionally NOT in `TOOLS`.
|
|
||||||
TOOL_CHECK_PROPOSAL = "check-proposal"
|
|
||||||
TOOLS: tuple[str, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
@@ -160,7 +156,6 @@ __all__ = [
|
|||||||
"TOOLS",
|
"TOOLS",
|
||||||
"TOOL_EGRESS_ALLOW",
|
"TOOL_EGRESS_ALLOW",
|
||||||
"TOOL_EGRESS_BLOCK",
|
"TOOL_EGRESS_BLOCK",
|
||||||
"TOOL_CHECK_PROPOSAL",
|
|
||||||
"TOOL_EGRESS_TOKEN_ALLOW",
|
"TOOL_EGRESS_TOKEN_ALLOW",
|
||||||
"TOOL_GITLEAKS_ALLOW",
|
"TOOL_GITLEAKS_ALLOW",
|
||||||
"TOOL_LIST_EGRESS_ROUTES",
|
"TOOL_LIST_EGRESS_ROUTES",
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# PRD prd-new: Claude forward_host_credentials
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-01
|
||||||
|
- **Issue:** #325
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add `agent_provider.forward_host_credentials: true` support for the
|
||||||
|
`claude` template, mirroring the existing Codex flow. When enabled,
|
||||||
|
bot-bottle reads the host's Claude OAuth session key from
|
||||||
|
`~/.claude/.credentials.json` at launch, forwards it only to the egress sidecar,
|
||||||
|
and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so
|
||||||
|
Claude Code starts without ever seeing the real credential.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Running a Claude agent in a container today requires the operator to
|
||||||
|
manually extract a long-lived OAuth token (`claude setup-token`), export
|
||||||
|
it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in
|
||||||
|
the manifest with `agent_provider.auth_token:
|
||||||
|
"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony
|
||||||
|
that is easy to skip or do incorrectly.
|
||||||
|
|
||||||
|
The host already stores a valid Claude session in `~/.claude/.credentials.json`
|
||||||
|
after `claude login`. Codex already automates an
|
||||||
|
equivalent extraction from `~/.codex/auth.json`. There is no reason
|
||||||
|
Claude bottles cannot do the same.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- A Claude bottle with `forward_host_credentials: true` in the manifest
|
||||||
|
uses the host's `~/.claude/.credentials.json` session key at launch with no
|
||||||
|
additional operator steps.
|
||||||
|
- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`
|
||||||
|
— never the real token.
|
||||||
|
- The real session key lives only in the egress sidecar's environment.
|
||||||
|
- Missing, malformed, or expired host Claude auth fails launch with a
|
||||||
|
clear operator-facing message.
|
||||||
|
- Existing `auth_token` behavior is unchanged.
|
||||||
|
- `forward_host_credentials: true` is rejected in the manifest when both
|
||||||
|
`auth_token` and `forward_host_credentials` are set, since they serve
|
||||||
|
the same purpose.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Refreshing Claude OAuth tokens in the sidecar.
|
||||||
|
- Writing a dummy `~/.claude.json` auth state to the agent (unlike the
|
||||||
|
Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
|
in env, not from an auth file — no guest-side auth marker is needed).
|
||||||
|
- Supporting `forward_host_credentials` for providers other than `codex`
|
||||||
|
and `claude`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Manifest schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent_provider:
|
||||||
|
template: claude
|
||||||
|
forward_host_credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Rejects in manifest validation when:
|
||||||
|
- Template is not `codex` or `claude`.
|
||||||
|
- Both `auth_token` and `forward_host_credentials` are set.
|
||||||
|
|
||||||
|
### Host auth extraction (`contrib/claude/claude_auth.py`)
|
||||||
|
|
||||||
|
Claude Code credential storage varies by platform:
|
||||||
|
|
||||||
|
- **Linux**: `~/.claude/.credentials.json`
|
||||||
|
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
|
||||||
|
(the file path is tried first; Keychain is the fallback when the file
|
||||||
|
is absent)
|
||||||
|
|
||||||
|
`~/.claude.json` contains only UI state and profile metadata — no token.
|
||||||
|
|
||||||
|
The credentials JSON schema (same whether from file or Keychain):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "<access-token>",
|
||||||
|
"refreshToken": "<refresh-token>",
|
||||||
|
"expiresAt": 1748276587173,
|
||||||
|
"scopes": ["user:inference", "user:profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expiresAt` is in **milliseconds** (not seconds).
|
||||||
|
|
||||||
|
At prepare/launch time, when `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
|
||||||
|
`security find-generic-password -s "Claude Code-credentials" -w`
|
||||||
|
and parse its stdout as JSON.
|
||||||
|
2. Require a `claudeAiOauth` dict.
|
||||||
|
3. Require a non-empty `claudeAiOauth.accessToken` string.
|
||||||
|
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
|
||||||
|
the result to be in the future.
|
||||||
|
5. Return only the access token to the launch path.
|
||||||
|
|
||||||
|
Errors name the missing or invalid condition and point the operator at
|
||||||
|
`claude login`, without printing token values.
|
||||||
|
|
||||||
|
### Egress route
|
||||||
|
|
||||||
|
When `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
- Provision the session key in `provisioned_env` under
|
||||||
|
`BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`).
|
||||||
|
- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer`
|
||||||
|
and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`.
|
||||||
|
- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and
|
||||||
|
add it to `hidden_env_names`.
|
||||||
|
|
||||||
|
No dummy auth file and no `verify` step are needed — Claude Code reads
|
||||||
|
the credential from the env var, not from a file.
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"`
|
||||||
|
in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`).
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in
|
||||||
|
`agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`).
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host ~/.claude/.credentials.json → bot-bottle launch
|
||||||
|
│
|
||||||
|
├──► egress sidecar env (real token only)
|
||||||
|
│
|
||||||
|
└──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder
|
||||||
|
|
||||||
|
Agent → HTTPS to api.anthropic.com (via egress)
|
||||||
|
Egress → injects Authorization: Bearer <real token>
|
||||||
|
Egress → forwards to api.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None — the Codex precedent makes the design clear.
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# PRD prd-new: Non-blocking supervise (async approval + proposal polling)
|
|
||||||
|
|
||||||
- **Status:** Draft
|
|
||||||
- **Author:** didericis
|
|
||||||
- **Created:** 2026-07-18
|
|
||||||
- **Issue:** #412
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The per-bottle supervise MCP server (`bot_bottle/supervise_server.py`)
|
|
||||||
answers `tools/call` **synchronously**: it queues the agent's proposal and
|
|
||||||
blocks the tool call polling for the operator's decision. On timeout it
|
|
||||||
returns `status: pending` and leaves the proposal queued — but it hands the
|
|
||||||
agent **no proposal id** and offers **no way to poll a specific pending
|
|
||||||
proposal**, so the only way to learn the outcome is to re-propose (a
|
|
||||||
duplicate).
|
|
||||||
|
|
||||||
This PRD makes the MCP flow non-blocking and pollable, so an approval can
|
|
||||||
happen out-of-band (a human taking minutes-to-hours in a review console)
|
|
||||||
without holding an HTTP request open or wedging the agent:
|
|
||||||
|
|
||||||
1. Include the `proposal_id` in the `pending` response.
|
|
||||||
2. Add a `check-proposal` MCP tool: a non-blocking status lookup by
|
|
||||||
proposal id.
|
|
||||||
3. Keep the short synchronous grace window for the common "operator is
|
|
||||||
right there" fast path.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`handle_tools_call` → `_sv.wait_for_response(...)` blocks up to
|
|
||||||
`SUPERVISE_RESPONSE_TIMEOUT_SECONDS` (default 30s). Two problems follow:
|
|
||||||
|
|
||||||
- **Human latency ≠ tool-call latency.** A real review — rendered diff,
|
|
||||||
RBAC routing to an approver, someone tapping approve on their phone — is
|
|
||||||
minutes-to-hours. Holding the MCP request open that long is fragile
|
|
||||||
(proxy/keepalive timeouts, the mitmproxy egress hop, and the agent
|
|
||||||
harness's own tool-call timeout, which a long block can trip and stall
|
|
||||||
the whole turn).
|
|
||||||
- **No resume path.** The pending fallback already exists, but without a
|
|
||||||
proposal id and a poll tool the agent can't reconnect to that specific
|
|
||||||
decision — it re-proposes, duplicating the queue entry.
|
|
||||||
|
|
||||||
This is also the precondition for the planned web-console human-review
|
|
||||||
flow (RBAC, audit retention, mobile) — see issue #412.
|
|
||||||
|
|
||||||
**Safety note:** the MCP tools only *propose* policy changes; enforcement
|
|
||||||
stays at the egress proxy and the git-gate. Returning early on `pending`
|
|
||||||
therefore opens no hole — the agent still cannot egress or push anything
|
|
||||||
unapproved.
|
|
||||||
|
|
||||||
## Goals / success criteria
|
|
||||||
|
|
||||||
- A `pending` MCP response carries the `proposal_id`.
|
|
||||||
- An agent can call `check-proposal(proposal_id)` and get the current
|
|
||||||
state (`pending` | `approved` | `modified` | `rejected`) **without
|
|
||||||
blocking** and **without creating a new proposal**.
|
|
||||||
- The synchronous fast path (operator approves within the grace window) is
|
|
||||||
unchanged: the first `tools/call` still returns the decision directly.
|
|
||||||
- No change to enforcement, attribution (source-IP → bottle), or the
|
|
||||||
operator-side queue/response schema.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- The git-gate `pre-receive` path (it is synchronous by nature and cannot
|
|
||||||
poll — its async variant is reject-fast + re-push; tracked as a
|
|
||||||
follow-up).
|
|
||||||
- Backpressure / in-flight-proposal caps.
|
|
||||||
- MCP server→client notifications (event-driven resume).
|
|
||||||
- Any web-console UI (this PRD is the protocol groundwork it needs).
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### `pending` response carries the id
|
|
||||||
|
|
||||||
`handle_tools_call`'s timeout branch formats the pending text with the
|
|
||||||
`proposal.id` and a pointer to `check-proposal`, so the agent knows what to
|
|
||||||
poll.
|
|
||||||
|
|
||||||
### `check-proposal` tool
|
|
||||||
|
|
||||||
A new read-only MCP tool (`TOOL_CHECK_PROPOSAL = "check-proposal"`),
|
|
||||||
attributed to the calling bottle by source IP exactly like the proposal
|
|
||||||
tools. Input: `{ "proposal_id": string }`. Behavior:
|
|
||||||
|
|
||||||
1. `read_response(slug, id)` →
|
|
||||||
- **found**: archive the proposal (same terminal step the synchronous
|
|
||||||
path takes) and return the decision via `format_response_text`;
|
|
||||||
`isError` iff rejected.
|
|
||||||
2. **not found** → `read_proposal(slug, id)` →
|
|
||||||
- **found**: still queued → return `status: pending`.
|
|
||||||
- **not found**: unknown id, or already resolved-and-archived (e.g. a
|
|
||||||
second poll) → return `status: unknown`, `isError: true`.
|
|
||||||
|
|
||||||
Both lookups already raise `FileNotFoundError` when absent
|
|
||||||
(`queue_store.py`), so the handler needs no new store methods. `check-`
|
|
||||||
`proposal` is the only path (besides the synchronous response) that
|
|
||||||
archives, so a proposal that times out to `pending` stays visible to the
|
|
||||||
operator until it is decided and then polled.
|
|
||||||
|
|
||||||
### Grace window
|
|
||||||
|
|
||||||
Left at the existing 30s default (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`),
|
|
||||||
which doubles as the instant-approve fast path. Tuning it down is an
|
|
||||||
operator setting, not a code change; noted for the console rollout.
|
|
||||||
|
|
||||||
## Implementation chunks
|
|
||||||
|
|
||||||
1. **(this PR)** `TOOL_CHECK_PROPOSAL` constant; `check-proposal` tool
|
|
||||||
definition + `handle_check_proposal`; dispatch wiring; `proposal_id` in
|
|
||||||
the pending text; unit tests. Files: `bot_bottle/supervise_types.py`,
|
|
||||||
`bot_bottle/supervise.py` (re-export), `bot_bottle/supervise_server.py`,
|
|
||||||
`tests/unit/test_supervise_server.py`.
|
|
||||||
2. **(follow-up)** git-gate `pre-receive` reject-fast + re-push.
|
|
||||||
3. **(follow-up)** per-bottle in-flight-proposal backpressure cap.
|
|
||||||
4. **(follow-up)** MCP notifications for event-driven resume; web-console
|
|
||||||
review flow (RBAC, audit retention) on top.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- Should a resolved-but-unpolled proposal auto-archive after some TTL, or
|
|
||||||
only on poll? (Leaning: only on poll, so a decision is never lost to a
|
|
||||||
reaper before the agent sees it.)
|
|
||||||
- Does the agent harness need an explicit "you have a pending proposal"
|
|
||||||
nudge, or is returning `pending` from the original call enough? (Deferred
|
|
||||||
to the notifications chunk.)
|
|
||||||
@@ -14,34 +14,22 @@ detectors, PRD 0017 / 0053), and git-push secret scanning is handled by
|
|||||||
**gitleaks** in the git-gate. "pipelock" below has been replaced with the
|
**gitleaks** in the git-gate. "pipelock" below has been replaced with the
|
||||||
current mechanism; it survives only in older PRDs as history.
|
current mechanism; it survives only in older PRDs as history.
|
||||||
|
|
||||||
Updated again 2026-07-18: six additional tools added (Cleanroom,
|
|
||||||
container-use, Docker sbx, Anthropic srt, Microsoft AGT, Open Agent
|
|
||||||
Passport); an **Agent-tailored policy** row added to the comparison table;
|
|
||||||
a separate Governance layers section added for AGT and OAP. See the
|
|
||||||
second addendum at the end.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Fifteen projects surveyed across two categories: isolation/sandbox tools
|
Nine projects surveyed. None duplicate bot-bottle's combination of
|
||||||
and governance/pre-action authorization layers (the latter don't provide
|
local VM-per-bottle isolation (Firecracker microVM on KVM Linux, Apple
|
||||||
VM or container isolation but do per-agent policy enforcement at the
|
Container on macOS — Docker is now only the legacy fallback), a
|
||||||
tool-call level). None duplicate bot-bottle's combination of local
|
declarative JSON manifest, per-agent egress allowlist + outbound-content
|
||||||
VM-per-bottle isolation, a declarative per-role manifest, per-agent
|
DLP via bot-bottle's own egress scanner (plus gitleaks secret-scanning on
|
||||||
egress allowlist + outbound-content DLP, bottle/agent split, and the
|
git push), and bottle/agent split. Two clusters stand out:
|
||||||
composable `extends:` policy model. Three clusters stand out:
|
|
||||||
|
|
||||||
- **Closest neighbours** — agent-safehouse and litterbox: local,
|
- **Closest neighbours** — agent-safehouse and litterbox: local,
|
||||||
single-user, thin wrappers over an existing OS primitive
|
single-user, thin wrappers over an existing OS primitive
|
||||||
(`sandbox-exec`, Podman + Landlock).
|
(`sandbox-exec`, Podman + Landlock).
|
||||||
- **Different category (isolation)** — tilde.run (hosted SaaS), boxlite
|
- **Different category** — tilde.run (hosted SaaS), boxlite and
|
||||||
and microsandbox (microVM libraries for platform builders), CubeSandbox
|
microsandbox (microVM libraries for platform builders), CubeSandbox
|
||||||
(self-hosted multi-tenant microVM service), endo-familiar
|
(self-hosted multi-tenant microVM service), endo-familiar
|
||||||
(capability-security paradigm, no OS isolation).
|
(capability-security paradigm, no OS isolation).
|
||||||
- **New: governance/pre-action layers** — Microsoft AGT and Open Agent
|
|
||||||
Passport (OAP): framework-embedded tool-call interceptors with
|
|
||||||
per-agent declarative policy. Closest competitors on agent-tailored
|
|
||||||
policy, but operate at the tool-call level rather than providing
|
|
||||||
network/filesystem isolation; they complement rather than substitute.
|
|
||||||
|
|
||||||
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
|
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
|
||||||
CubeSandbox) is the most relevant for the v2 isolation discussion in
|
CubeSandbox) is the most relevant for the v2 isolation discussion in
|
||||||
@@ -222,157 +210,20 @@ claim that the monetization positioning leans on. See the addendum.
|
|||||||
- **Maturity**: Open-sourced July 2026 off production Tencent Cloud use;
|
- **Maturity**: Open-sourced July 2026 off production Tencent Cloud use;
|
||||||
most-starred project in this set (~10.4k).
|
most-starred project in this set (~10.4k).
|
||||||
|
|
||||||
### Cleanroom *(added 2026-07-18)*
|
|
||||||
- **Source**: https://github.com/buildkite/cleanroom
|
|
||||||
- **License**: Apache 2.0
|
|
||||||
- **Isolation**: MicroVM — Firecracker on Linux, Virtualization.framework
|
|
||||||
on macOS. Digest-pinned OCI images.
|
|
||||||
- **Locality**: Self-hosted server (CI-oriented).
|
|
||||||
- **Agent integration**: Generic process sandbox; CI-first, not a
|
|
||||||
Claude/agent wrapper.
|
|
||||||
- **Config**: `cleanroom.yaml` in the repo being sandboxed defines egress
|
|
||||||
rules, resources, and network policy. Cleanroom resolves this from the
|
|
||||||
commit being run.
|
|
||||||
- **Network policy**: Default-deny + per-repo hostname allowlist (resolved
|
|
||||||
from DNS answers + destination IP:port). Co-hosted services on the same
|
|
||||||
IP:port are not distinguished. OIDC-backed auth for remote servers.
|
|
||||||
- **Credentials**: Host-side only; not injected in-flight but not present
|
|
||||||
in the VM.
|
|
||||||
- **Notable**: Policy lives in the *repo being sandboxed*, not in an
|
|
||||||
agent-role definition — closer to per-repo scoping than per-role.
|
|
||||||
Supports Docker-inside-sandbox (`services.docker.required: true`), OIDC
|
|
||||||
authorization, suspend/resume lifecycle.
|
|
||||||
- **Maturity**: Active Buildkite product.
|
|
||||||
|
|
||||||
### container-use *(added 2026-07-18)*
|
|
||||||
- **Source**: https://github.com/dagger/container-use
|
|
||||||
- **License**: Apache 2.0
|
|
||||||
- **Isolation**: Docker container per agent + git worktree per agent.
|
|
||||||
Containers share the host kernel; stronger than bare host but weaker
|
|
||||||
than microVM.
|
|
||||||
- **Locality**: Local.
|
|
||||||
- **Agent integration**: MCP stdio server — Claude Code, Cursor, Windsurf.
|
|
||||||
`claude mcp add container-use -- container-use stdio`.
|
|
||||||
- **Config**: None for security policy. Environments are provisioned on
|
|
||||||
demand; no allowlist or credential config.
|
|
||||||
- **Network policy**: Not addressed.
|
|
||||||
- **Notable**: Per-agent git branches (`container-use/<env_name>`);
|
|
||||||
parallel agents without filesystem conflict; real-time log visibility
|
|
||||||
and terminal attach for intervention; git-based review workflow.
|
|
||||||
Oriented toward parallel development safety, not security containment.
|
|
||||||
- **Maturity**: Early development, active.
|
|
||||||
|
|
||||||
### Docker sbx *(added 2026-07-18)*
|
|
||||||
- **Source**: Docker proprietary (`sbx` CLI, separate from `docker`).
|
|
||||||
- **License**: Proprietary.
|
|
||||||
- **Isolation**: MicroVM (Docker's own implementation) — each session gets
|
|
||||||
its own kernel, Docker daemon inside the VM, and filesystem.
|
|
||||||
- **Locality**: Local (macOS and Windows; does not require Docker Desktop).
|
|
||||||
- **Agent integration**: Explicit wrapper — Claude Code, Codex, Gemini
|
|
||||||
CLI, Copilot CLI, Kiro. Launches agent inside the VM with
|
|
||||||
`--dangerously-skip-permissions` by default.
|
|
||||||
- **Config**: Open / Balanced / Locked Down network presets at launch. No
|
|
||||||
per-role manifest.
|
|
||||||
- **Network policy**: Default-deny; preset levels control strictness. TUI
|
|
||||||
dashboard shows a live log of every outbound connection (allowed and
|
|
||||||
blocked) with point-and-click allow/block for hosts.
|
|
||||||
- **Credentials**: OS keychain + host-side proxy injection — API keys
|
|
||||||
never enter the VM.
|
|
||||||
- **Notable**: Best DX among microVM tools (one command, works like native
|
|
||||||
yolo Claude but inside a VM); branch mode creates a git worktree in
|
|
||||||
`.sbx/`. Network policy is preset-based, not role-declarative.
|
|
||||||
- **Maturity**: GA 2026.
|
|
||||||
|
|
||||||
### Anthropic srt *(added 2026-07-18)*
|
|
||||||
- **Source**: https://github.com/anthropic-experimental/sandbox-runtime
|
|
||||||
(`@anthropic-ai/sandbox-runtime` on npm, `sandbox-runtime` on PyPI)
|
|
||||||
- **License**: Apache 2.0 (experimental).
|
|
||||||
- **Isolation**: OS-level only — Seatbelt (`sandbox-exec`) on macOS,
|
|
||||||
bubblewrap on Linux, WFP (Windows Filtering Platform) account-fenced on
|
|
||||||
Windows. **No container or VM.** Lowest overhead in the set.
|
|
||||||
- **Locality**: Local.
|
|
||||||
- **Agent integration**: Claude Code's sandboxed bash tool uses this
|
|
||||||
internally. Can wrap any arbitrary process (`srt <command>`). Cloud
|
|
||||||
Claude Code sessions use full microVMs instead.
|
|
||||||
- **Config**: Programmatic per-invocation — allow/deny path lists for
|
|
||||||
filesystem; allow/denylist for network (HTTP proxy + SOCKS5).
|
|
||||||
- **Network policy**: Proxy-based filtering (HTTP + SOCKS5); domain
|
|
||||||
allowlist/denylist enforced at proxy layer. Custom proxy supported
|
|
||||||
(e.g. mitmproxy for inspection + audit). Processes that ignore proxy
|
|
||||||
env vars may bypass filtering on some platforms.
|
|
||||||
- **Notable**: Cross-platform (macOS/Linux/Windows); wraps any process,
|
|
||||||
not just agents; no role/manifest concept. Annotated as a research
|
|
||||||
preview — APIs may change.
|
|
||||||
- **Maturity**: Early research preview.
|
|
||||||
|
|
||||||
## Governance / pre-action authorization layers
|
|
||||||
|
|
||||||
These two tools don't provide VM or filesystem isolation; they intercept
|
|
||||||
tool calls before execution and evaluate them against a per-agent
|
|
||||||
declarative policy. They are the closest competitors on **agent-tailored
|
|
||||||
policy** and complement isolation sandboxes rather than substituting for
|
|
||||||
them.
|
|
||||||
|
|
||||||
### Microsoft Agent Governance Toolkit (AGT) *(added 2026-07-18)*
|
|
||||||
- **Source**: https://github.com/microsoft/agent-governance-toolkit
|
|
||||||
- **License**: MIT (~3.3k stars, open-sourced April 2, 2026).
|
|
||||||
- **Isolation**: None (OS/VM). Execution rings (0–3, inspired by CPU
|
|
||||||
privilege levels) control what an agent can do at the framework layer.
|
|
||||||
MCP security gateway treats MCP traffic as an untrusted boundary.
|
|
||||||
- **Locality**: Embedded in the agent framework (Python, TypeScript, .NET,
|
|
||||||
Rust, Go; 20+ framework adapters).
|
|
||||||
- **Agent integration**: Framework-agnostic. Plugs into Semantic Kernel,
|
|
||||||
AutoGen, and others as a middleware layer.
|
|
||||||
- **Config**: YAML policy per agent — tools can be `allowed`, `denied`,
|
|
||||||
`sandboxed`, or routed through an `approval` step. Every action passes
|
|
||||||
through a governance gate checking: agent DID, trust score, risk tier,
|
|
||||||
requested tool, action type, and policy rules.
|
|
||||||
- **Network policy**: Not directly — operates at tool-call level.
|
|
||||||
- **Credentials**: Per-agent DID (Ed25519 decentralized identifier); agent
|
|
||||||
does not borrow a human's credentials.
|
|
||||||
- **Notable**: Dynamic trust score (0–1,000, behavioral decay) —
|
|
||||||
privilege follows observed behaviour, not just provisioning. Covers all
|
|
||||||
10 OWASP Agentic Top 10 risks. Kill switch + SLO monitoring. Sub-ms
|
|
||||||
policy enforcement.
|
|
||||||
- **Maturity**: MIT, ~3.3k ⭐, v3.7.0 May 2026.
|
|
||||||
|
|
||||||
### Open Agent Passport (OAP) *(added 2026-07-18)*
|
|
||||||
- **Source**: https://github.com/aporthq/aport-spec ; spec at
|
|
||||||
https://api.aport.io/spec/spec/oap/oap-spec.md/ ; arXiv 2603.20953
|
|
||||||
- **License**: Open specification.
|
|
||||||
- **Isolation**: None. Pre-action hook only — intercepts tool calls
|
|
||||||
synchronously before execution, evaluates against a cloud-registry
|
|
||||||
declarative policy, fails closed.
|
|
||||||
- **Locality**: Local hook + cloud policy registry.
|
|
||||||
- **Agent integration**: Framework-agnostic; hook pattern.
|
|
||||||
- **Config**: Declarative policy rules in a cloud registry (evaluated in
|
|
||||||
order; first failing rule denies). Ed25519-signed, hash-chained audit
|
|
||||||
records per decision.
|
|
||||||
- **Network policy**: Not directly.
|
|
||||||
- **Notable**: 53ms median authorization decision (N=1,000). In an
|
|
||||||
adversarial testbed ($5,000 bounty, 1,151 sessions), social engineering
|
|
||||||
succeeded 74.6% of the time under a permissive policy; under a
|
|
||||||
restrictive OAP policy, 0% success across 879 attempts. Assumes
|
|
||||||
framework runtime is not compromised.
|
|
||||||
- **Maturity**: Specification + reference implementation, 2026.
|
|
||||||
|
|
||||||
## Comparison table
|
## Comparison table
|
||||||
|
|
||||||
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
|
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox | Cleanroom | container-use | Docker sbx | Anthropic srt |
|
| Isolation | MicroVM per bottle default (Firecracker/KVM on Linux, Apple Container on macOS) + own egress DLP scanner; Docker legacy fallback, gVisor there if present | Object-capability (no OS isolation) | Podman + opt. Landlock | macOS `sandbox-exec` | MicroVM (Firecracker / Virt.fw) | Hosted container (unverified) | MicroVM (KVM / Hypervisor.fw) | MicroVM (libkrun) | MicroVM (libkrun / KVM) | MicroVM (RustVMM / KVM) |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) |
|
||||||
| Isolation | MicroVM per bottle default (Firecracker/KVM on Linux, Apple Container on macOS) + own egress DLP scanner; Docker legacy fallback, gVisor there if present | Object-capability (no OS isolation) | Podman + opt. Landlock | macOS `sandbox-exec` | MicroVM (Firecracker / Virt.fw) | Hosted container (unverified) | MicroVM (KVM / Hypervisor.fw) | MicroVM (libkrun) | MicroVM (libkrun / KVM) | MicroVM (RustVMM / KVM) | MicroVM (Firecracker / Virt.fw) | Docker container + git worktree | MicroVM (proprietary) | OS-level (Seatbelt / bubblewrap / WFP) — no container |
|
| Open source | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | MIT | No | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 |
|
||||||
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) | Self-hosted server | Local | Local | Local |
|
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) | E2B-compatible (platform builders) |
|
||||||
| Open source | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | MIT | No | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | Proprietary | Apache 2.0 (experimental) |
|
| Network policy | Default-deny via own egress scanner + per-bottle allowlist + content DLP + gitleaks on git push | Capability model only | Limited | Not addressed | Default-deny + allowlist + secret-injecting proxy | Default-deny + logging | Per-VM net (unverified) | Not documented | Off by default + allowlist | Default-deny allowlist + instant egress block + audit logs + per-sandbox tokens (eBPF) + credential vault |
|
||||||
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) | E2B-compatible (platform builders) | CI / generic process | Claude Code, Cursor, Windsurf (MCP) | Claude Code, Codex, Gemini CLI, Copilot, Kiro | Claude Code (and any process) |
|
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural | Yes (2,000+/host claimed) |
|
||||||
| Network policy | Default-deny via own egress scanner + per-bottle allowlist + content DLP + gitleaks on git push | Capability model only | Limited | Not addressed | Default-deny + allowlist + secret-injecting proxy | Default-deny + logging | Per-VM net (unverified) | Not documented | Off by default + allowlist | Default-deny allowlist + instant egress block + audit logs + per-sandbox tokens (eBPF) + credential vault | Default-deny + per-repo host allowlist (cleanroom.yaml) | Not addressed | Default-deny; Open / Balanced / Locked Down presets; live TUI network panel | Proxy-based allowlist/denylist (HTTP + SOCKS5); custom proxy supported |
|
| Long-running posture | Persistent by default (named, supervised) | n/a (demo) | Session (up while in use) | Per-invocation | Ephemeral VM per run | Per-run (versioned) | Ephemeral + snapshot/fork | Ephemeral / on-demand | Named persistent by default | Ephemeral + auto pause/resume |
|
||||||
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural | Yes (2,000+/host claimed) | Yes (server model) | Yes (per-agent containers + worktrees) | Yes | Yes |
|
| DX: run Claude yolo-style | One command → interactive yolo Claude (`start <agent>`, `--dangerously-skip-permissions` default) | n/a (lib demo) | Wizard + build, then run claude inside (Linux only) | One-command wrapper (`safehouse claude --dangerously-skip-permissions`) | CLI: run a cmd in a VM (not a Claude wrapper) | Hosted (`tilde exec`), not local-native | SDK code required (build the run yourself) | CLI/MCP: sandbox-as-a-tool for the agent, not a wrapper around it | SSH into a named machine, run claude there | Stand up a cluster + drive via E2B SDK |
|
||||||
| Long-running posture | Persistent by default (named, supervised) | n/a (demo) | Session (up while in use) | Per-invocation | Ephemeral VM per run | Per-run (versioned) | Ephemeral + snapshot/fork | Ephemeral / on-demand | Named persistent by default | Ephemeral + auto pause/resume | Per-run + suspend/resume | Per-agent container (ephemeral) | Per-session; branch mode creates git worktree in .sbx/ | Per-invocation |
|
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile | E2B-compatible SDK |
|
||||||
| DX: run Claude yolo-style | One command → interactive yolo Claude (`start <agent>`, `--dangerously-skip-permissions` default) | n/a (lib demo) | Wizard + build, then run claude inside (Linux only) | One-command wrapper (`safehouse claude --dangerously-skip-permissions`) | CLI: run a cmd in a VM (not a Claude wrapper) | Hosted (`tilde exec`), not local-native | SDK code required (build the run yourself) | CLI/MCP: sandbox-as-a-tool for the agent, not a wrapper around it | SSH into a named machine, run claude there | Stand up a cluster + drive via E2B SDK | CI-oriented, not a Claude wrapper | MCP server: `claude mcp add container-use -- container-use stdio` | One command: `sbx` wraps claude with `--dangerously-skip-permissions` default | Library/wrapper, not a standalone CLI |
|
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | Tencent, prod, ~10.4k ⭐ |
|
||||||
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile | E2B-compatible SDK | cleanroom.yaml in repo | None (no policy config) | Preset levels at launch | Programmatic per-invocation (allow/deny lists) |
|
|
||||||
| Agent-tailored policy | Yes — bottle/agent split; declarative per-role egress + credentials; composable via `extends:` | Partial — capability model scopes per-agent, but no declarative role manifest | No | Partial — per-agent profile files (Seatbelt); no egress | No | Yes — per-agent DSL RBAC (allow/deny/approve per action/repo/agent) | No | No | No | No — per-sandbox SDK config, not role-scoped | Partial — per-repo cleanroom.yaml, not per-role | No | No — network presets only | No |
|
|
||||||
| Maturity | Active July 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | Tencent, prod, ~10.4k ⭐ | Active (Buildkite product) | Early development | GA 2026 | Early research preview |
|
|
||||||
|
|
||||||
## What's closest, what's different
|
## What's closest, what's different
|
||||||
|
|
||||||
@@ -382,41 +233,11 @@ existing OS primitive, low-dep. The split is the isolation primitive —
|
|||||||
bot-bottle now defaults to a VM per bottle (Firecracker microVM on KVM
|
bot-bottle now defaults to a VM per bottle (Firecracker microVM on KVM
|
||||||
Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
|
Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
|
||||||
keeping Docker only as a legacy fallback; agent-safehouse uses
|
keeping Docker only as a legacy fallback; agent-safehouse uses
|
||||||
`sandbox-exec`; litterbox uses Podman + Landlock. matchlock and
|
`sandbox-exec`; litterbox
|
||||||
smolmachines are close on *both* the policy side (default-deny net,
|
uses Podman + Landlock. matchlock and smolmachines are close on *both* the
|
||||||
per-host allowlist) and — now that bot-bottle has moved off
|
policy side (default-deny net, per-host allowlist) and — now that
|
||||||
containers-by-default — the microVM isolation primitive.
|
bot-bottle has moved off containers-by-default — the microVM isolation
|
||||||
|
primitive.
|
||||||
**New closest on agent-tailored policy.** Two governance tools are the
|
|
||||||
direct competitors on the "coarse-grained sandbox" axis. **tilde.run**
|
|
||||||
has had per-agent DSL RBAC since its launch (though it's hosted SaaS).
|
|
||||||
**Microsoft AGT** is the most serious new entrant: per-agent DID
|
|
||||||
identity, YAML policy that can allow/deny/sandbox/approve individual tool
|
|
||||||
calls per agent, and a dynamic behavioural trust score. It operates at
|
|
||||||
the framework tool-call layer, not the network layer — so it's
|
|
||||||
complementary to bot-bottle's network/filesystem isolation rather than a
|
|
||||||
direct substitute, but on the "does this sandbox know what this agent is
|
|
||||||
for?" question it is the most complete answer in the field. OAP's
|
|
||||||
pre-action hook pattern achieves similar goals with cryptographic audit
|
|
||||||
and a 0% adversarial-attack success rate under a restrictive policy.
|
|
||||||
|
|
||||||
**New closest on DX.** **Docker sbx** is the first tool in this set that
|
|
||||||
matches bot-bottle on the "one command, dangerously-skip-permissions safe
|
|
||||||
by default" DX bar, at microVM isolation strength, with host-side
|
|
||||||
credential injection. It is proprietary, preset-based (not role-
|
|
||||||
declarative), and cloud-agent-specific, but it directly competes on the
|
|
||||||
UX proposition. agent-safehouse was the previous DX peer; Docker sbx
|
|
||||||
materially raises the bar.
|
|
||||||
|
|
||||||
**New closest on repo-scoped policy.** **Cleanroom** (Buildkite) is the
|
|
||||||
first tool to combine microVM isolation with a declarative egress policy
|
|
||||||
file — though the policy lives in the repo being sandboxed
|
|
||||||
(`cleanroom.yaml`), not in an agent-role manifest. That makes it per-
|
|
||||||
repo rather than per-role: the same Cleanroom config applies to any
|
|
||||||
agent running in that repo. The distinction matters for bot-bottle's
|
|
||||||
use case (one developer running multiple agent *roles* with different
|
|
||||||
egress footprints), but for CI/CD use cases Cleanroom is a direct
|
|
||||||
alternative.
|
|
||||||
|
|
||||||
**Solving a different problem.** tilde.run is hosted SaaS for team /
|
**Solving a different problem.** tilde.run is hosted SaaS for team /
|
||||||
production agent pipelines with data-versioned rollback — explicitly
|
production agent pipelines with data-versioned rollback — explicitly
|
||||||
@@ -588,57 +409,3 @@ Why it matters anyway:
|
|||||||
and per-sandbox egress tokens (eBPF virtual switch vs. bot-bottle's
|
and per-sandbox egress tokens (eBPF virtual switch vs. bot-bottle's
|
||||||
mitmproxy egress proxy) before the next iteration of bot-bottle's
|
mitmproxy egress proxy) before the next iteration of bot-bottle's
|
||||||
in-flight-secret feature — see borrowable idea #2 above.
|
in-flight-secret feature — see borrowable idea #2 above.
|
||||||
|
|
||||||
## Addendum 2026-07-18 (second pass) — agent-tailored policy landscape
|
|
||||||
|
|
||||||
The second-pass question was: how novel is bot-bottle's per-agent,
|
|
||||||
role-tailored sandbox relative to the expanded field?
|
|
||||||
|
|
||||||
**The short answer:** on the isolation + network + role-tailoring
|
|
||||||
combination, bot-bottle remains the only tool in this set. On
|
|
||||||
role-tailored *policy at the tool-call level*, Microsoft AGT and OAP are
|
|
||||||
the most complete answers, but they don't provide isolation; they
|
|
||||||
complement rather than substitute.
|
|
||||||
|
|
||||||
**The competitive picture by axis:**
|
|
||||||
|
|
||||||
- *Agent-tailored egress (declarative, per-role)* — bot-bottle and
|
|
||||||
tilde.run. Cleanroom is per-repo, not per-role. Everyone else is
|
|
||||||
per-session or not addressed.
|
|
||||||
- *Agent-tailored tool-call policy (declarative, per-agent identity)* —
|
|
||||||
Microsoft AGT (YAML policy + DID identity + trust score), OAP
|
|
||||||
(declarative policy rules + cryptographic audit). Neither provides
|
|
||||||
network/filesystem isolation.
|
|
||||||
- *Composable policy (role overlays)* — bot-bottle (`extends:`). No
|
|
||||||
other tool surveyed supports composable role-policy inheritance.
|
|
||||||
- *Isolation + DX (one-command safe yolo)* — bot-bottle and Docker sbx.
|
|
||||||
Docker sbx is proprietary, preset-based, and cloud-agent-specific;
|
|
||||||
it's the first DX-class competitor at microVM isolation strength.
|
|
||||||
|
|
||||||
**What the HN "coarse-grained" complaint maps to:** The complaint is
|
|
||||||
that a VM isolates the filesystem but doesn't know if the agent
|
|
||||||
*should* be sending an email. bot-bottle's bottle/agent split is a
|
|
||||||
structural answer to this: the bottle manifest declares exactly what
|
|
||||||
the role can reach, and the sandbox enforces it at the network layer.
|
|
||||||
Microsoft AGT is the most complete answer at the semantic/tool-call
|
|
||||||
layer. The gap both leave open is *intent classification* — knowing
|
|
||||||
whether a permitted action is consistent with the agent's actual task.
|
|
||||||
See `hn-agent-safety-discourse-july-2026.md` for the blast-radius
|
|
||||||
analysis.
|
|
||||||
|
|
||||||
**Borrowable from new tools:**
|
|
||||||
|
|
||||||
- **Microsoft AGT's trust-score decay** — privilege that reflects
|
|
||||||
observed behaviour rather than static provisioning. Applied to
|
|
||||||
bot-bottle: a bottle that has triggered DLP alerts or supervise holds
|
|
||||||
could auto-downgrade its network preset, or flag the session for
|
|
||||||
closer review. Fits the existing supervise-server architecture.
|
|
||||||
- **Docker sbx's live network TUI** — real-time per-session view of
|
|
||||||
allowed and blocked outbound connections with point-and-click
|
|
||||||
allow/block. `cli.py supervise` is the right surface; adding a
|
|
||||||
live-connections panel would directly address the "I can't see what
|
|
||||||
the agent is doing" gap without any backend changes.
|
|
||||||
- **OAP's cryptographic audit chain** — Ed25519-signed, hash-chained
|
|
||||||
audit records. Currently bot-bottle logs egress decisions but doesn't
|
|
||||||
chain them. A tamper-evident audit record per session would be useful
|
|
||||||
for the compliance use case the CubeSandbox positioning targets.
|
|
||||||
|
|||||||
@@ -204,43 +204,6 @@ MCP STDIO server from within the agent is still sandboxed by the VM, and
|
|||||||
any outbound calls from that server must pass the egress allowlist and
|
any outbound calls from that server must pass the egress allowlist and
|
||||||
outbound DLP scanner.
|
outbound DLP scanner.
|
||||||
|
|
||||||
**Per-agent role tailoring (the "coarse-grained sandbox" complaint)**
|
|
||||||
|
|
||||||
The Feb 2026 HN thread that argued "sandboxes are too coarse-grained"
|
|
||||||
was pointing at a real gap: a VM isolates the filesystem but doesn't
|
|
||||||
know whether an agent *should* be sending email or calling an external
|
|
||||||
API. bot-bottle's bottle/agent split is a structural answer at the
|
|
||||||
network layer — the bottle manifest declares exactly what each role can
|
|
||||||
reach (which hosts, which paths, which HTTP methods), and the egress
|
|
||||||
scanner enforces it. A `gitea-dev` bottle that only lists
|
|
||||||
`gitea.dideric.is` and `api.anthropic.com` structurally cannot send
|
|
||||||
email or reach AWS, not because the model was told not to, but because
|
|
||||||
those routes don't exist.
|
|
||||||
|
|
||||||
The `extends:` composition model means provider-level policy (the Claude
|
|
||||||
auth route) lives in one base bottle and role-specific overlays are
|
|
||||||
stacked on top — no duplication, and changing the base propagates to all
|
|
||||||
derived roles.
|
|
||||||
|
|
||||||
Competitive position on this axis (from `agent-sandbox-landscape.md`):
|
|
||||||
|
|
||||||
| Tool | Agent-tailored policy |
|
|
||||||
|---|---|
|
|
||||||
| **bot-bottle** | Yes — declarative per-role manifest; `extends:` composition; egress + credentials scoped to role |
|
|
||||||
| **tilde.run** | Yes — per-agent DSL RBAC (allow/deny/approve per action/repo/agent), but hosted SaaS |
|
|
||||||
| **Microsoft AGT** | Yes — YAML policy + per-agent DID + trust score, but tool-call level only (no network isolation) |
|
|
||||||
| **OAP** | Yes — declarative pre-action policy + cryptographic audit, but no isolation |
|
|
||||||
| **Cleanroom** | Partial — per-repo `cleanroom.yaml`, not per-role |
|
|
||||||
| **Docker sbx** | No — network presets only |
|
|
||||||
| **Anthropic srt** | No — programmatic per-invocation |
|
|
||||||
| **matchlock / smolmachines / microsandbox** | No |
|
|
||||||
| **agent-safehouse** | Partial — per-agent Seatbelt profiles; no egress |
|
|
||||||
|
|
||||||
Two takeaways: bot-bottle and tilde.run are the only isolation tools
|
|
||||||
with declarative role-tailored policy; Microsoft AGT and OAP are the
|
|
||||||
closest competitors on role-tailoring but operate at the tool-call layer
|
|
||||||
without network/filesystem isolation — complementary, not substitutes.
|
|
||||||
|
|
||||||
**Outbound exfiltration (any injection class)**
|
**Outbound exfiltration (any injection class)**
|
||||||
|
|
||||||
Whatever triggers the agent — README injection, Agentjacking, MCP
|
Whatever triggers the agent — README injection, Agentjacking, MCP
|
||||||
|
|||||||
+1
-2
@@ -26,8 +26,7 @@ rm -f .coverage
|
|||||||
echo "== unit ==" >&2
|
echo "== unit ==" >&2
|
||||||
"$PY" -m coverage run -m unittest discover -t . -s tests/unit
|
"$PY" -m coverage run -m unittest discover -t . -s tests/unit
|
||||||
|
|
||||||
echo "== integration (firecracker; skips docker tests) ==" >&2
|
echo "== integration (skips without Docker) ==" >&2
|
||||||
BOT_BOTTLE_BACKEND=firecracker SKIP_DOCKER_TESTS=1 \
|
|
||||||
"$PY" -m coverage run --append -m unittest discover -t . -s tests/integration
|
"$PY" -m coverage run --append -m unittest discover -t . -s tests/integration
|
||||||
|
|
||||||
echo "== combined report ==" >&2
|
echo "== combined report ==" >&2
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Resolved paths to system binaries used by subprocess-based tests.
|
|
||||||
|
|
||||||
NixOS and other non-FHS hosts don't populate ``/bin`` (there is no
|
|
||||||
``/bin/sleep``), so tests that spawn real short-lived helper processes
|
|
||||||
must resolve the binary from ``PATH`` rather than hardcoding an FHS path.
|
|
||||||
Import the resolved constant (e.g. ``SLEEP``) instead of writing
|
|
||||||
``/bin/sleep`` inline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(name: str, fallback: str) -> str:
|
|
||||||
"""Absolute path to ``name`` from ``PATH``; ``fallback`` on FHS hosts
|
|
||||||
where the binary isn't on ``PATH`` but lives at a known ``/bin`` path."""
|
|
||||||
return shutil.which(name) or fallback
|
|
||||||
|
|
||||||
|
|
||||||
# Real ``sleep`` binary. ``/bin/sleep`` is absent on NixOS; resolve from
|
|
||||||
# PATH so subprocess tests run instead of erroring with FileNotFoundError.
|
|
||||||
SLEEP = resolve("sleep", "/bin/sleep")
|
|
||||||
@@ -2,44 +2,24 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
def docker_available() -> bool:
|
def docker_available() -> bool:
|
||||||
if os.environ.get("SKIP_DOCKER_TESTS"):
|
|
||||||
return False
|
|
||||||
if shutil.which("docker") is None:
|
if shutil.which("docker") is None:
|
||||||
return False
|
return False
|
||||||
try:
|
|
||||||
return (
|
return (
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "info"],
|
["docker", "info"],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
check=False,
|
check=False,
|
||||||
timeout=5,
|
|
||||||
).returncode
|
).returncode
|
||||||
== 0
|
== 0
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def skip_unless_docker(reason: str = "docker unreachable"):
|
def skip_unless_docker(reason: str = "docker unreachable"):
|
||||||
return unittest.skipUnless(docker_available(), reason)
|
return unittest.skipUnless(docker_available(), reason)
|
||||||
|
|
||||||
|
|
||||||
def skip_unless_docker_or_firecracker(
|
|
||||||
reason: str = "neither Docker nor Firecracker selected",
|
|
||||||
):
|
|
||||||
"""Skip a backend-agnostic test unless one supported backend can run.
|
|
||||||
|
|
||||||
Firecracker does not require the host Docker daemon. The KVM coverage job
|
|
||||||
deliberately sets ``SKIP_DOCKER_TESTS`` to exclude Docker-only integration
|
|
||||||
classes while still exercising this path.
|
|
||||||
"""
|
|
||||||
firecracker_selected = os.environ.get("BOT_BOTTLE_BACKEND") == "firecracker"
|
|
||||||
return unittest.skipUnless(firecracker_selected or docker_available(), reason)
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from pathlib import Path
|
|||||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||||
from bot_bottle.bottle_state import cleanup_state
|
from bot_bottle.bottle_state import cleanup_state
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests._docker import skip_unless_docker_or_firecracker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
# Secrets planted in the bottle env as literals (agents substitute via
|
# Secrets planted in the bottle env as literals (agents substitute via
|
||||||
@@ -67,14 +67,13 @@ _DUMMY_HOST_KEY = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker_or_firecracker()
|
@skip_unless_docker()
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true"
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
"skipped under act_runner: egress_tls_init uses a host bind mount "
|
||||||
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
|
"the runner container can't see, and the network topology hides "
|
||||||
"egress_tls_init uses a host bind mount the runner container can't "
|
"sibling-gateway visibility — same constraint as the other "
|
||||||
"see, and the network topology hides sibling-gateway visibility — "
|
"bottle-bringup integration tests",
|
||||||
"these constraints don't apply on the self-hosted KVM runner",
|
|
||||||
)
|
)
|
||||||
class TestSandboxEscape(unittest.TestCase):
|
class TestSandboxEscape(unittest.TestCase):
|
||||||
"""End-to-end attacks against a real bottle. The bottle stays
|
"""End-to-end attacks against a real bottle. The bottle stays
|
||||||
@@ -91,9 +90,11 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
# Pin Docker when BOT_BOTTLE_BACKEND is unset to preserve the
|
# Docker is always required (the agent + companion containers run under it,
|
||||||
# Docker-backed CI path. Firecracker uses its persistent infra VM for
|
# and VM backends still use it for the gateway); the
|
||||||
# the shared gateway and therefore does not require host Docker.
|
# class-level @skip_unless_docker already covers that. Pin
|
||||||
|
# Docker when BOT_BOTTLE_BACKEND is unset to preserve the
|
||||||
|
# Docker-backed CI path.
|
||||||
cls._backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
|
cls._backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
|
||||||
|
|
||||||
# Throwaway static key for the git-gate fixture. It need not
|
# Throwaway static key for the git-gate fixture. It need not
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS,
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
build_agent_provision_plan,
|
build_agent_provision_plan,
|
||||||
prompt_args,
|
prompt_args,
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
from bot_bottle.egress import (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _jwt(exp: int) -> str:
|
def _jwt(exp: int) -> str:
|
||||||
@@ -292,6 +296,67 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual({}, plan.provisioned_env)
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
|
def test_claude_forward_host_credentials_populates_egress_route(self):
|
||||||
|
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
home = Path(tmp) / "host-claude"
|
||||||
|
cred_dir = home / ".claude"
|
||||||
|
cred_dir.mkdir(parents=True)
|
||||||
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
|
}))
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=True,
|
||||||
|
host_env={"HOME": str(home)},
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(plan.egress_routes))
|
||||||
|
route = plan.egress_routes[0]
|
||||||
|
self.assertIn(route.host, CLAUDE_HOST_CREDENTIAL_HOSTS)
|
||||||
|
self.assertEqual("Bearer", route.auth_scheme)
|
||||||
|
self.assertEqual(CLAUDE_HOST_CREDENTIAL_TOKEN_REF, route.token_ref)
|
||||||
|
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
|
||||||
|
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||||
|
|
||||||
|
def test_claude_forward_host_credentials_populates_provisioned_env(self):
|
||||||
|
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
home = Path(tmp) / "host-claude"
|
||||||
|
cred_dir = home / ".claude"
|
||||||
|
cred_dir.mkdir(parents=True)
|
||||||
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
|
}))
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=True,
|
||||||
|
host_env={"HOME": str(home)},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
|
||||||
|
plan.provisioned_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claude_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=False,
|
||||||
|
)
|
||||||
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
def test_pi_plan_writes_default_ollama_models(self):
|
def test_pi_plan_writes_default_ollama_models(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = build_agent_provision_plan(
|
||||||
|
|||||||
@@ -215,18 +215,6 @@ class TestDockerSetupStatus(unittest.TestCase):
|
|||||||
with patch.object(dk.shutil, "which", return_value=None):
|
with patch.object(dk.shutil, "which", return_value=None):
|
||||||
self.assertFalse(dk._daemon_reachable())
|
self.assertFalse(dk._daemon_reachable())
|
||||||
|
|
||||||
def test_daemon_reachable_true_when_daemon_responds(self):
|
|
||||||
with patch.object(dk.shutil, "which", return_value="/usr/bin/docker"), \
|
|
||||||
patch.object(dk.subprocess, "run",
|
|
||||||
return_value=subprocess.CompletedProcess([], 0)):
|
|
||||||
self.assertTrue(dk._daemon_reachable())
|
|
||||||
|
|
||||||
def test_daemon_reachable_false_on_timeout(self):
|
|
||||||
with patch.object(dk.shutil, "which", return_value="/usr/bin/docker"), \
|
|
||||||
patch.object(dk.subprocess, "run",
|
|
||||||
side_effect=subprocess.TimeoutExpired(["docker", "info"], 5)):
|
|
||||||
self.assertFalse(dk._daemon_reachable())
|
|
||||||
|
|
||||||
def test_status_reports_missing_docker(self):
|
def test_status_reports_missing_docker(self):
|
||||||
with patch.object(dk.shutil, "which", return_value=None):
|
with patch.object(dk.shutil, "which", return_value=None):
|
||||||
rc, out = _cap(dk.status)
|
rc, out = _cap(dk.status)
|
||||||
|
|||||||
@@ -95,60 +95,5 @@ class TestMainDispatch(unittest.TestCase):
|
|||||||
self.assertEqual(130, main(["x"]))
|
self.assertEqual(130, main(["x"]))
|
||||||
|
|
||||||
|
|
||||||
class TestMigrationGate(unittest.TestCase):
|
|
||||||
"""The dispatcher's schema-migration gate (cli/__init__.py)."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
# Force the "schema out of date" branch for every test here.
|
|
||||||
patcher = patch.object(StoreManager, "is_migrated", return_value=False)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
self.addCleanup(StoreManager.reset)
|
|
||||||
|
|
||||||
def test_store_command_blocks_when_stdin_cannot_confirm(self) -> None:
|
|
||||||
# Non-TTY stdin at EOF (as in CI): the [y/N] prompt reads "" and the
|
|
||||||
# command is refused rather than migrating silently.
|
|
||||||
ran: list[bool] = []
|
|
||||||
|
|
||||||
def handler(_rest: list[str]) -> int:
|
|
||||||
ran.append(True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
with patch.dict(climod.COMMANDS, {"list": handler}), \
|
|
||||||
patch("sys.stdin", io.StringIO("")), \
|
|
||||||
patch("sys.stderr", io.StringIO()):
|
|
||||||
self.assertEqual(1, main(["list"]))
|
|
||||||
self.assertEqual([], ran, "gated command must not dispatch")
|
|
||||||
|
|
||||||
def test_backend_command_skips_gate(self) -> None:
|
|
||||||
# `backend` provisions/probes the host and never opens the store, so
|
|
||||||
# it must run even on an unmigrated DB with unanswerable stdin.
|
|
||||||
ran: list[bool] = []
|
|
||||||
|
|
||||||
def handler(_rest: list[str]) -> int:
|
|
||||||
ran.append(True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
with patch.dict(climod.COMMANDS, {"backend": handler}), \
|
|
||||||
patch("sys.stdin", io.StringIO("")), \
|
|
||||||
patch("sys.stderr", io.StringIO()):
|
|
||||||
self.assertEqual(0, main(["backend", "status"]))
|
|
||||||
self.assertEqual([True], ran, "exempt command must dispatch")
|
|
||||||
|
|
||||||
def test_store_command_migrates_on_confirmation(self) -> None:
|
|
||||||
migrated: list[bool] = []
|
|
||||||
|
|
||||||
def handler(_rest: list[str]) -> int:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
with patch.dict(climod.COMMANDS, {"list": handler}), \
|
|
||||||
patch.object(StoreManager, "migrate",
|
|
||||||
side_effect=lambda: migrated.append(True)), \
|
|
||||||
patch("sys.stdin", io.StringIO("y\n")), \
|
|
||||||
patch("sys.stderr", io.StringIO()):
|
|
||||||
self.assertEqual(0, main(["list"]))
|
|
||||||
self.assertEqual([True], migrated, "confirmed gate must migrate")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -57,14 +57,6 @@ class TestCmdStartSelector(unittest.TestCase):
|
|||||||
self._bottle_picker_mock = self._bottle_picker_patch.start()
|
self._bottle_picker_mock = self._bottle_picker_patch.start()
|
||||||
self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected
|
self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected
|
||||||
|
|
||||||
# name_color_modal opens /dev/tty and blocks on keyboard input on
|
|
||||||
# self-hosted runners that have a real controlling terminal. Stub it
|
|
||||||
# out like the other tui pickers so tests don't wait for a keypress.
|
|
||||||
self._modal_patch = patch.object(
|
|
||||||
tui_mod, "name_color_modal", return_value=("researcher", ""),
|
|
||||||
)
|
|
||||||
self._modal_patch.start()
|
|
||||||
|
|
||||||
self._env_patch = patch.dict(os.environ, {}, clear=False)
|
self._env_patch = patch.dict(os.environ, {}, clear=False)
|
||||||
self._env_patch.start()
|
self._env_patch.start()
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||||
@@ -74,7 +66,6 @@ class TestCmdStartSelector(unittest.TestCase):
|
|||||||
self._launch_patch.stop()
|
self._launch_patch.stop()
|
||||||
self._agent_picker_patch.stop()
|
self._agent_picker_patch.stop()
|
||||||
self._bottle_picker_patch.stop()
|
self._bottle_picker_patch.stop()
|
||||||
self._modal_patch.stop()
|
|
||||||
self._env_patch.stop()
|
self._env_patch.stop()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Unit: host Claude auth extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.contrib.claude.claude_auth import (
|
||||||
|
claude_auth_path,
|
||||||
|
claude_host_access_token,
|
||||||
|
)
|
||||||
|
from bot_bottle.log import Die
|
||||||
|
|
||||||
|
|
||||||
|
def _cred_json(access_token: str, **extra: object) -> str:
|
||||||
|
payload: dict[str, object] = {"claudeAiOauth": {"accessToken": access_token, **extra}}
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeHostAccessToken(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
|
||||||
|
self.home = Path(self.tmp.name)
|
||||||
|
self.cred_dir = self.home / ".claude"
|
||||||
|
self.cred_dir.mkdir()
|
||||||
|
self.auth_path = self.cred_dir / ".credentials.json"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
|
||||||
|
self.auth_path.write_text(json.dumps(payload))
|
||||||
|
|
||||||
|
def test_auth_path_uses_home_env(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth_path,
|
||||||
|
claude_auth_path({"HOME": str(self.home)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- file-based (Linux) ---
|
||||||
|
|
||||||
|
def test_file_returns_access_token(self):
|
||||||
|
key = "sk-ant-oat01-real-key" # gitleaks:allow
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_missing_claude_ai_oauth_dies(self):
|
||||||
|
self._write({"hasCompletedOnboarding": True})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_missing_access_token_dies(self):
|
||||||
|
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_empty_access_token_dies(self):
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": ""}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_expired_token_dies(self):
|
||||||
|
# expiresAt is milliseconds; 1_000_000 ms is year 1970
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, # gitleaks:allow
|
||||||
|
})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token(
|
||||||
|
{"HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_file_future_expiry_is_accepted(self):
|
||||||
|
key = "sk-ant-oat01-y" # gitleaks:allow
|
||||||
|
# 2_000_000_000_000 ms ≈ year 2033
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
|
||||||
|
})
|
||||||
|
out = claude_host_access_token(
|
||||||
|
{"HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_absent_expiry_is_accepted(self):
|
||||||
|
key = "sk-ant-oat01-z" # gitleaks:allow
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_non_json_dies(self):
|
||||||
|
self.auth_path.write_text("not json {{{")
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_json_array_root_dies(self):
|
||||||
|
self.auth_path.write_text("[]")
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_extra_fields_are_ignored(self):
|
||||||
|
key = "sk-ant-oat01-real" # gitleaks:allow
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": key,
|
||||||
|
"refreshToken": "sk-ant-ort01-secret", # gitleaks:allow
|
||||||
|
"scopes": ["user:inference"],
|
||||||
|
"expiresAt": 2_000_000_000_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
# --- macOS Keychain fallback ---
|
||||||
|
|
||||||
|
def _home_without_creds(self) -> Path:
|
||||||
|
"""A home dir that has .claude/ but no .credentials.json."""
|
||||||
|
empty = self.home / "no-creds"
|
||||||
|
(empty / ".claude").mkdir(parents=True)
|
||||||
|
return empty
|
||||||
|
|
||||||
|
def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock:
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.returncode = returncode
|
||||||
|
mock.stdout = stdout
|
||||||
|
return mock
|
||||||
|
|
||||||
|
def test_keychain_used_when_file_absent(self):
|
||||||
|
key = "sk-ant-oat01-keychain" # gitleaks:allow
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain(_cred_json(key)),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
out = claude_host_access_token({"HOME": str(home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_keychain_failure_when_file_absent_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain("", returncode=44),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_no_file_no_keychain_on_linux_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_keychain_non_json_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain("not-json"),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_keychain_security_not_found_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
side_effect=FileNotFoundError,
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Unit: DbStore._connection() context manager and is_migrated()."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot_bottle.db_store import DbStore
|
|
||||||
from bot_bottle.migrations import TableMigrations
|
|
||||||
|
|
||||||
|
|
||||||
def _store(tmp: Path) -> DbStore:
|
|
||||||
migrations = TableMigrations("test", ["CREATE TABLE items (id INTEGER PRIMARY KEY)"])
|
|
||||||
return DbStore(tmp / "test.db", migrations)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDbStoreIsMigrated(unittest.TestCase):
|
|
||||||
def test_returns_false_when_db_absent(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
store = _store(Path(d))
|
|
||||||
self.assertFalse(store.is_migrated())
|
|
||||||
|
|
||||||
def test_returns_false_when_schema_versions_missing(self):
|
|
||||||
# DB file exists but has no schema_versions table → OperationalError → False.
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
store = _store(Path(d))
|
|
||||||
conn = sqlite3.connect(store.db_path)
|
|
||||||
conn.close()
|
|
||||||
self.assertFalse(store.is_migrated())
|
|
||||||
|
|
||||||
def test_returns_true_after_migrate(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
store = _store(Path(d))
|
|
||||||
store.migrate()
|
|
||||||
self.assertTrue(store.is_migrated())
|
|
||||||
|
|
||||||
def test_returns_false_when_behind(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
migrations = TableMigrations(
|
|
||||||
"test",
|
|
||||||
[
|
|
||||||
"CREATE TABLE items (id INTEGER PRIMARY KEY)",
|
|
||||||
"ALTER TABLE items ADD COLUMN name TEXT",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
store = DbStore(Path(d) / "test.db", migrations)
|
|
||||||
# Apply only the first migration manually.
|
|
||||||
conn = sqlite3.connect(store.db_path)
|
|
||||||
with conn:
|
|
||||||
TableMigrations("test", [migrations.migrations[0]]).apply(conn)
|
|
||||||
conn.close()
|
|
||||||
self.assertFalse(store.is_migrated())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Tests for integration-test backend selection helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from tests._docker import skip_unless_docker_or_firecracker
|
|
||||||
|
|
||||||
|
|
||||||
class TestSkipUnlessDockerOrFirecracker(unittest.TestCase):
|
|
||||||
def test_firecracker_runs_when_docker_tests_are_disabled(self):
|
|
||||||
with patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{"BOT_BOTTLE_BACKEND": "firecracker", "SKIP_DOCKER_TESTS": "1"},
|
|
||||||
clear=True,
|
|
||||||
):
|
|
||||||
decorated = skip_unless_docker_or_firecracker()(type("Case", (), {}))
|
|
||||||
|
|
||||||
self.assertFalse(getattr(decorated, "__unittest_skip__", False))
|
|
||||||
|
|
||||||
def test_non_firecracker_still_skips_when_docker_tests_are_disabled(self):
|
|
||||||
with patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{"BOT_BOTTLE_BACKEND": "docker", "SKIP_DOCKER_TESTS": "1"},
|
|
||||||
clear=True,
|
|
||||||
):
|
|
||||||
decorated = skip_unless_docker_or_firecracker()(type("Case", (), {}))
|
|
||||||
|
|
||||||
self.assertTrue(decorated.__unittest_skip__)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Tests both the helper functions in `bot_bottle.gateway_init`
|
Tests both the helper functions in `bot_bottle.gateway_init`
|
||||||
and the supervisor's end-to-end signal / exit-code behavior. The
|
and the supervisor's end-to-end signal / exit-code behavior. The
|
||||||
end-to-end tests use real subprocesses (`sleep`, `/bin/sh -c '...'`) —
|
end-to-end tests use real subprocesses (`/bin/sleep`,
|
||||||
short-lived, no docker required — so they run under `tests/unit/`
|
`/bin/sh -c '...'`) — short-lived, no docker required — so they
|
||||||
rather than `tests/integration/`."""
|
run under `tests/unit/` rather than `tests/integration/`."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ from bot_bottle.gateway_init import (
|
|||||||
_env_for_daemon,
|
_env_for_daemon,
|
||||||
_selected_daemons,
|
_selected_daemons,
|
||||||
)
|
)
|
||||||
from tests._bin import SLEEP
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnvForDaemon(unittest.TestCase):
|
class TestEnvForDaemon(unittest.TestCase):
|
||||||
@@ -183,7 +182,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# up and the supervisor never set shutdown_at.
|
# up and the supervisor never set shutdown_at.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
||||||
_DaemonSpec("longrun", (SLEEP, "30")),
|
_DaemonSpec("longrun", ("/bin/sleep", "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -215,7 +214,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# signal-killed longrun's negative returncode.
|
# signal-killed longrun's negative returncode.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
||||||
_DaemonSpec("longrun", (SLEEP, "30")),
|
_DaemonSpec("longrun", ("/bin/sleep", "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -260,7 +259,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("egress", sighup_marker),
|
_DaemonSpec("egress", sighup_marker),
|
||||||
_DaemonSpec("other", (SLEEP, "30")),
|
_DaemonSpec("other", ("/bin/sleep", "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -283,7 +282,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_forward_signal_unknown_daemon_no_op(self):
|
def test_forward_signal_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", (SLEEP, "30"))]
|
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
delivered = sup.forward_signal(signal.SIGHUP, "ghost")
|
delivered = sup.forward_signal(signal.SIGHUP, "ghost")
|
||||||
@@ -295,8 +294,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# Restart one daemon; the other (supervise, the MCP server
|
# Restart one daemon; the other (supervise, the MCP server
|
||||||
# in production) must remain untouched.
|
# in production) must remain untouched.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("git-gate", (SLEEP, "30")),
|
_DaemonSpec("git-gate", ("/bin/sleep", "30")),
|
||||||
_DaemonSpec("supervise", (SLEEP, "30")),
|
_DaemonSpec("supervise", ("/bin/sleep", "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -320,8 +319,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
|
|
||||||
def test_request_restart_is_drained_by_tick(self):
|
def test_request_restart_is_drained_by_tick(self):
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("git-gate", (SLEEP, "30")),
|
_DaemonSpec("git-gate", ("/bin/sleep", "30")),
|
||||||
_DaemonSpec("supervise", (SLEEP, "30")),
|
_DaemonSpec("supervise", ("/bin/sleep", "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -344,7 +343,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_repeated_restart_requests_coalesce(self):
|
def test_repeated_restart_requests_coalesce(self):
|
||||||
specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
|
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -367,7 +366,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_request_restart_unknown_daemon_no_op(self):
|
def test_request_restart_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", (SLEEP, "30"))]
|
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
ok = sup.request_restart("ghost")
|
ok = sup.request_restart("ghost")
|
||||||
@@ -377,7 +376,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_restart_unknown_daemon_no_op(self):
|
def test_restart_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", (SLEEP, "30"))]
|
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
ok = sup.restart_daemon("ghost")
|
ok = sup.restart_daemon("ghost")
|
||||||
@@ -386,7 +385,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_restart_during_shutdown_is_no_op(self):
|
def test_restart_during_shutdown_is_no_op(self):
|
||||||
specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
|
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
sup.request_shutdown(reason="test")
|
sup.request_shutdown(reason="test")
|
||||||
@@ -396,7 +395,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_pending_restart_dropped_during_shutdown(self):
|
def test_pending_restart_dropped_during_shutdown(self):
|
||||||
specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
|
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -414,8 +413,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# both should receive SIGTERM and exit. Signal-only
|
# both should receive SIGTERM and exit. Signal-only
|
||||||
# shutdown clamps to a zero supervisor exit code.
|
# shutdown clamps to a zero supervisor exit code.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("a", (SLEEP, "60")),
|
_DaemonSpec("a", ("/bin/sleep", "60")),
|
||||||
_DaemonSpec("b", (SLEEP, "60")),
|
_DaemonSpec("b", ("/bin/sleep", "60")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -450,7 +449,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
|
|
||||||
def test_idempotent_shutdown_requests(self):
|
def test_idempotent_shutdown_requests(self):
|
||||||
specs = [_DaemonSpec("a", (SLEEP, "60"))]
|
specs = [_DaemonSpec("a", ("/bin/sleep", "60"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -471,7 +470,7 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
for p in ("/bin/sh", SLEEP):
|
for p in ("/bin/sh", "/bin/sleep"):
|
||||||
if not Path(p).exists():
|
if not Path(p).exists():
|
||||||
raise unittest.SkipTest(f"missing {p}")
|
raise unittest.SkipTest(f"missing {p}")
|
||||||
|
|
||||||
@@ -486,8 +485,8 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
"import os, runpy, sys\n"
|
"import os, runpy, sys\n"
|
||||||
"from bot_bottle import gateway_init as si\n"
|
"from bot_bottle import gateway_init as si\n"
|
||||||
"si._DAEMONS = (\n"
|
"si._DAEMONS = (\n"
|
||||||
f" si._DaemonSpec('alpha', ({SLEEP!r},'30')),\n"
|
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
|
||||||
f" si._DaemonSpec('beta', ({SLEEP!r},'30')),\n"
|
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
|
||||||
")\n"
|
")\n"
|
||||||
"sys.exit(si.main([]))\n"
|
"sys.exit(si.main([]))\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -168,18 +168,16 @@ class TestHookRender(unittest.TestCase):
|
|||||||
# Stdin is buffered to a tempfile so both phases can re-read.
|
# Stdin is buffered to a tempfile so both phases can re-read.
|
||||||
self.assertIn("refs_file=$(mktemp)", hook)
|
self.assertIn("refs_file=$(mktemp)", hook)
|
||||||
|
|
||||||
def test_scan_scoped_to_incoming_commits(self):
|
def test_new_ref_scan_scoped_to_incoming_commits(self):
|
||||||
# Every non-delete push scans only commits new to the gate, not
|
# A new branch (old=all-zeros) must scan only commits new to the
|
||||||
# the full ancestry and not the `$old..$new` delta — otherwise
|
# gate, not the full ancestry — otherwise historical findings
|
||||||
# historical fixtures block new-branch pushes (PRD 0028 / #106)
|
# block every new-branch push (PRD 0028 / issue #106).
|
||||||
# and a rebase/force-push onto an advanced main drags in main's
|
|
||||||
# history incl. the sandbox-escape fixtures (#346).
|
|
||||||
hook = git_gate_render_hook()
|
hook = git_gate_render_hook()
|
||||||
self.assertIn('log_opts="$new --not --all"', hook)
|
self.assertIn('log_opts="$new --not --all"', hook)
|
||||||
# Neither the full-ancestry range nor the ancestry-blind delta
|
# The old over-broad full-ancestry range must be gone.
|
||||||
# range may survive.
|
|
||||||
self.assertNotIn('log_opts="$new"', hook)
|
self.assertNotIn('log_opts="$new"', hook)
|
||||||
self.assertNotIn('log_opts="$old..$new"', hook)
|
# Existing-branch delta scan is unchanged.
|
||||||
|
self.assertIn('log_opts="$old..$new"', hook)
|
||||||
|
|
||||||
def test_forward_ssh_is_non_interactive_and_bounded(self):
|
def test_forward_ssh_is_non_interactive_and_bounded(self):
|
||||||
# No prompt (BatchMode) and a connect timeout, so an unreachable
|
# No prompt (BatchMode) and a connect timeout, so an unreachable
|
||||||
|
|||||||
@@ -80,11 +80,19 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
|||||||
"forward_host_credentials": "yes",
|
"forward_host_credentials": "yes",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_forward_host_credentials_rejected_for_claude(self):
|
def test_forward_host_credentials_allowed_for_claude(self):
|
||||||
|
b = _provider_config_bottle({
|
||||||
|
"template": "claude",
|
||||||
|
"forward_host_credentials": True,
|
||||||
|
})
|
||||||
|
self.assertTrue(b.agent_provider.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_host_credentials_and_auth_token_rejected_together(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
_provider_config_bottle({
|
_provider_config_bottle({
|
||||||
"template": "claude",
|
"template": "claude",
|
||||||
"forward_host_credentials": True,
|
"forward_host_credentials": True,
|
||||||
|
"auth_token": "SOME_TOKEN",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_auth_token_defaults_empty(self):
|
def test_auth_token_defaults_empty(self):
|
||||||
|
|||||||
@@ -86,11 +86,23 @@ class TestAgentProviderValidation(unittest.TestCase):
|
|||||||
"b", {"forward_host_credentials": True, "template": "weird"}
|
"b", {"forward_host_credentials": True, "template": "weird"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_forward_creds_non_codex_template(self) -> None:
|
def test_forward_creds_pi_template_rejected(self) -> None:
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgentProvider.from_dict(
|
ManifestAgentProvider.from_dict(
|
||||||
|
"b", {"forward_host_credentials": True, "template": "pi"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forward_creds_claude_allowed(self) -> None:
|
||||||
|
p = ManifestAgentProvider.from_dict(
|
||||||
"b", {"forward_host_credentials": True, "template": "claude"}
|
"b", {"forward_host_credentials": True, "template": "claude"}
|
||||||
)
|
)
|
||||||
|
self.assertTrue(p.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_creds_and_auth_token_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
ManifestAgentProvider.from_dict(
|
||||||
|
"b", {"forward_host_credentials": True, "auth_token": "T", "template": "claude"}
|
||||||
|
)
|
||||||
|
|
||||||
def test_valid_claude_auth_token(self) -> None:
|
def test_valid_claude_auth_token(self) -> None:
|
||||||
p = ManifestAgentProvider.from_dict("b", {"template": "claude", "auth_token": "T"})
|
p = ManifestAgentProvider.from_dict("b", {"template": "claude", "auth_token": "T"})
|
||||||
|
|||||||
@@ -32,9 +32,7 @@ from bot_bottle.supervise_server import (
|
|||||||
_RpcError,
|
_RpcError,
|
||||||
_RpcInternalError,
|
_RpcInternalError,
|
||||||
_response_timeout_from_env,
|
_response_timeout_from_env,
|
||||||
format_pending_response_text,
|
|
||||||
format_response_text,
|
format_response_text,
|
||||||
handle_check_proposal,
|
|
||||||
handle_initialize,
|
handle_initialize,
|
||||||
handle_tools_call,
|
handle_tools_call,
|
||||||
handle_tools_list,
|
handle_tools_list,
|
||||||
@@ -220,7 +218,6 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
_sv.TOOL_EGRESS_ALLOW,
|
_sv.TOOL_EGRESS_ALLOW,
|
||||||
_sv.TOOL_EGRESS_BLOCK,
|
_sv.TOOL_EGRESS_BLOCK,
|
||||||
_sv.TOOL_LIST_EGRESS_ROUTES,
|
_sv.TOOL_LIST_EGRESS_ROUTES,
|
||||||
_sv.TOOL_CHECK_PROPOSAL,
|
|
||||||
]),
|
]),
|
||||||
sorted(names),
|
sorted(names),
|
||||||
)
|
)
|
||||||
@@ -487,10 +484,9 @@ class TestFormatResponseText(unittest.TestCase):
|
|||||||
|
|
||||||
class TestFormatPendingResponseText(unittest.TestCase):
|
class TestFormatPendingResponseText(unittest.TestCase):
|
||||||
def test_formats_timeout_message(self):
|
def test_formats_timeout_message(self):
|
||||||
text = supervise_server.format_pending_response_text("prop-9", 12.5)
|
text = supervise_server.format_pending_response_text(12.5)
|
||||||
self.assertIn("status: pending", text)
|
self.assertIn("status: pending", text)
|
||||||
self.assertIn("12.5s", text)
|
self.assertIn("12.5s", text)
|
||||||
self.assertIn("proposal_id: prop-9", text)
|
|
||||||
|
|
||||||
|
|
||||||
# --- End-to-end HTTP sanity ------------------------------------------------
|
# --- End-to-end HTTP sanity ------------------------------------------------
|
||||||
@@ -689,129 +685,5 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
_handler(None)._resolved_routes_payload()
|
_handler(None)._resolved_routes_payload()
|
||||||
|
|
||||||
|
|
||||||
class TestNonBlockingSupervise(unittest.TestCase):
|
|
||||||
"""PRD prd-new / issue #412: pending responses carry the proposal id, and
|
|
||||||
`check-proposal` polls a queued proposal without blocking or re-proposing."""
|
|
||||||
|
|
||||||
_ROUTES = "routes:\n - host: example.com\n"
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
|
|
||||||
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
|
|
||||||
self.config = ServerConfig(bottle_slug="dev")
|
|
||||||
_qs.QueueStore("dev").migrate()
|
|
||||||
_as.AuditStore().migrate()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._home_patch()
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def _seed_proposal(self) -> "_sv.Proposal":
|
|
||||||
p = _sv.Proposal.new(
|
|
||||||
bottle_slug="dev",
|
|
||||||
tool=_sv.TOOL_EGRESS_ALLOW,
|
|
||||||
proposed_file=self._ROUTES,
|
|
||||||
justification="need example.com",
|
|
||||||
current_file_hash=_sv.sha256_hex(self._ROUTES),
|
|
||||||
)
|
|
||||||
_sv.write_proposal(p)
|
|
||||||
return p
|
|
||||||
|
|
||||||
def _check(self, proposal_id: str) -> dict[str, object]:
|
|
||||||
return handle_check_proposal({"arguments": {"proposal_id": proposal_id}}, self.config)
|
|
||||||
|
|
||||||
# --- pending response carries the id ---
|
|
||||||
|
|
||||||
def test_pending_text_includes_id_and_pointer(self):
|
|
||||||
text = format_pending_response_text("abc-123", 30.0)
|
|
||||||
self.assertIn("status: pending", text)
|
|
||||||
self.assertIn("proposal_id: abc-123", text)
|
|
||||||
self.assertIn("check-proposal", text)
|
|
||||||
|
|
||||||
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
|
|
||||||
# No responder → the grace window expires → pending, not blocked forever.
|
|
||||||
result = handle_tools_call(
|
|
||||||
{
|
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
|
||||||
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
|
|
||||||
},
|
|
||||||
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
|
|
||||||
)
|
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
|
||||||
self.assertIn("status: pending", text)
|
|
||||||
pending = _sv.list_pending_proposals("dev")
|
|
||||||
self.assertEqual(1, len(pending)) # still queued, not archived
|
|
||||||
self.assertIn(pending[0].id, text) # agent got the id to poll
|
|
||||||
|
|
||||||
# --- check-proposal poll ---
|
|
||||||
|
|
||||||
def test_check_returns_approved_and_archives(self):
|
|
||||||
p = self._seed_proposal()
|
|
||||||
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
|
|
||||||
result = self._check(p.id)
|
|
||||||
self.assertFalse(result["isError"])
|
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
|
||||||
self.assertIn("status: approved", text)
|
|
||||||
self.assertIn("notes: ok", text)
|
|
||||||
with self.assertRaises(FileNotFoundError): # archived on read
|
|
||||||
_sv.read_proposal("dev", p.id)
|
|
||||||
|
|
||||||
def test_check_rejected_sets_isError(self):
|
|
||||||
p = self._seed_proposal()
|
|
||||||
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
|
|
||||||
result = self._check(p.id)
|
|
||||||
self.assertTrue(result["isError"])
|
|
||||||
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
|
|
||||||
|
|
||||||
def test_check_pending_when_no_decision_yet(self):
|
|
||||||
p = self._seed_proposal()
|
|
||||||
result = self._check(p.id)
|
|
||||||
self.assertFalse(result["isError"])
|
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
|
||||||
self.assertIn("status: pending", text)
|
|
||||||
self.assertIn(p.id, text)
|
|
||||||
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
|
|
||||||
|
|
||||||
def test_check_unknown_id_is_error(self):
|
|
||||||
result = self._check("no-such-proposal")
|
|
||||||
self.assertTrue(result["isError"])
|
|
||||||
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
|
|
||||||
|
|
||||||
def test_check_missing_id_raises(self):
|
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
|
||||||
handle_check_proposal({"arguments": {}}, self.config)
|
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
|
||||||
|
|
||||||
def test_check_empty_id_raises(self):
|
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
|
||||||
handle_check_proposal({"arguments": {"proposal_id": " "}}, self.config)
|
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
|
||||||
|
|
||||||
def test_check_arguments_must_be_object(self):
|
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
|
||||||
handle_check_proposal({"arguments": []}, self.config)
|
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
|
||||||
|
|
||||||
def test_full_nonblocking_round_trip(self):
|
|
||||||
# 1. tools/call times out → pending with id
|
|
||||||
result = handle_tools_call(
|
|
||||||
{
|
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
|
||||||
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
|
|
||||||
},
|
|
||||||
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
|
|
||||||
)
|
|
||||||
pid = _sv.list_pending_proposals("dev")[0].id
|
|
||||||
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
|
|
||||||
# 2. operator decides out-of-band
|
|
||||||
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
|
|
||||||
# 3. agent resumes by polling — no re-proposing
|
|
||||||
poll = self._check(pid)
|
|
||||||
self.assertFalse(poll["isError"])
|
|
||||||
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
|
|
||||||
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user