Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 121c3e82eb | |||
| 96977084fc | |||
| f26579c552 |
@@ -22,7 +22,10 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# No actions/setup-python: canaries are stdlib unittest on the image's
|
- name: Set up Python
|
||||||
# system Python 3.12 (older act_runner mishandles setup-python's PATH).
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Run canaries
|
- name: Run canaries
|
||||||
run: python3 -m unittest discover -t . -s tests/canaries -v
|
run: python3 -m unittest discover -t . -s tests/canaries -v
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
- name: Set up Python
|
||||||
# and older act_runner engines mishandle setup-python's PATH. Install
|
uses: actions/setup-python@v4
|
||||||
# into the ephemeral job container's system Python — the pylint/pyright
|
with:
|
||||||
# console scripts land on /usr/local/bin (on PATH) so the steps below
|
python-version: "3.12"
|
||||||
# still resolve. --break-system-packages is safe: the container is
|
|
||||||
# disposable.
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# No actions/setup-python: the inline script is stdlib-only on the
|
- name: Set up Python
|
||||||
# image's system Python 3.12 (older act_runner mishandles its PATH).
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
|
|||||||
+17
-14
@@ -34,13 +34,13 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
- name: Set up Python
|
||||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
uses: actions/setup-python@v5
|
||||||
# lands in one interpreter, `python3` resolves to another). Install
|
with:
|
||||||
# straight into the ephemeral job container's system Python —
|
python-version: "3.12"
|
||||||
# --break-system-packages is safe because the container is disposable.
|
|
||||||
- name: Install dev requirements
|
- name: Install dev requirements
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
run: python3 -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||||
@@ -54,8 +54,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# No actions/setup-python (see the note in the `unit` job); the
|
- name: Set up Python
|
||||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Show environment
|
- name: Show environment
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
python3 --version
|
||||||
@@ -85,13 +88,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
- name: Set up Python
|
||||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
uses: actions/setup-python@v5
|
||||||
# lands in one interpreter, `python3` resolves to another). Install
|
with:
|
||||||
# straight into the ephemeral job container's system Python —
|
python-version: "3.12"
|
||||||
# --break-system-packages is safe because the container is disposable.
|
|
||||||
- name: Install dev requirements
|
- name: Install dev requirements
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
run: python3 -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Combined coverage report (unit + integration)
|
- name: Combined coverage report (unit + integration)
|
||||||
run: PYTHON=python3 bash scripts/coverage.sh critical
|
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||||
|
|||||||
@@ -20,18 +20,21 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# No actions/setup-python: the runner image ships Python 3.12 and older
|
- name: Set up Python
|
||||||
# act_runner engines mishandle setup-python's PATH. Install into the
|
uses: actions/setup-python@v4
|
||||||
# ephemeral job container's system Python (--break-system-packages is
|
with:
|
||||||
# safe because the container is disposable).
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run coverage and extract percentage
|
- name: Run coverage and extract percentage
|
||||||
id: coverage
|
id: coverage
|
||||||
run: |
|
run: |
|
||||||
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||||
PERCENT=$(python3 -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Coverage: $PERCENT%"
|
echo "Coverage: $PERCENT%"
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ jobs:
|
|||||||
# the single source of truth in scripts/critical-modules.txt; every
|
# the single source of truth in scripts/critical-modules.txt; every
|
||||||
# core module is unit-tested, so the unit-only run is accurate for it.
|
# core module is unit-tested, so the unit-only run is accurate for it.
|
||||||
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
||||||
PERCENT=$(python3 -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Core coverage: $PERCENT%"
|
echo "Core coverage: $PERCENT%"
|
||||||
|
|
||||||
|
|||||||
+30
-33
@@ -16,12 +16,10 @@
|
|||||||
# Layout:
|
# Layout:
|
||||||
#
|
#
|
||||||
# /usr/bin/gitleaks gitleaks binary
|
# /usr/bin/gitleaks gitleaks binary
|
||||||
# /app/egress_addon.py mitmproxy addon entry point
|
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||||
# /app/egress-entrypoint.sh mitmdump launcher
|
# /app/egress-entrypoint.sh mitmdump launcher
|
||||||
# /usr/local/lib/python*/bot_bottle/ installed package (all daemons + shared modules)
|
# /app/supervise_server.py + .py supervise MCP server
|
||||||
# /app/egress_addon.py one-line shim: re-exports addons from package
|
# /app/gateway_init.py PID 1 supervisor
|
||||||
# (mitmdump -s requires a file path, not a module)
|
|
||||||
# /etc/egress/routes.yaml bind-mounted at run time
|
|
||||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
# /git-gate/creds/* docker-cp'd at start time
|
||||||
@@ -67,38 +65,35 @@ RUN pip install --no-cache-dir mitmproxy==11.1.3
|
|||||||
# would pin us to that image's cadence). python (already present) does the
|
# would pin us to that image's cadence). python (already present) does the
|
||||||
# download so we add no curl/wget. trixie apt also ships gitleaks, but an
|
# download so we add no curl/wget. trixie apt also ships gitleaks, but an
|
||||||
# older 8.16; the pinned download keeps the verified 8.30.1.
|
# older 8.16; the pinned download keeps the verified 8.30.1.
|
||||||
#
|
|
||||||
# Arch-aware: the asset + SHA are picked from the build's target
|
|
||||||
# architecture so an arm64 host (Apple Silicon) gets the arm64 binary
|
|
||||||
# rather than an x86_64 one that dies with "Exec format error" the first
|
|
||||||
# time the pre-receive hook runs it. TARGETARCH is auto-populated by
|
|
||||||
# BuildKit; the dpkg fallback keeps it correct under a legacy builder.
|
|
||||||
ARG GITLEAKS_VERSION=8.30.1
|
ARG GITLEAKS_VERSION=8.30.1
|
||||||
ARG GITLEAKS_SHA256_AMD64=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
ARG GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||||
ARG GITLEAKS_SHA256_ARM64=e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080
|
RUN url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||||
ARG TARGETARCH
|
|
||||||
RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}" \
|
|
||||||
&& case "$arch" in \
|
|
||||||
amd64) asset="linux_x64"; sha="${GITLEAKS_SHA256_AMD64}" ;; \
|
|
||||||
arm64) asset="linux_arm64"; sha="${GITLEAKS_SHA256_ARM64}" ;; \
|
|
||||||
*) echo "unsupported gitleaks target arch: $arch" >&2; exit 1 ;; \
|
|
||||||
esac \
|
|
||||||
&& url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${asset}.tar.gz" \
|
|
||||||
&& python3 -c "import sys,urllib.request; urllib.request.urlretrieve(sys.argv[1], '/tmp/gitleaks.tar.gz')" "$url" \
|
&& python3 -c "import sys,urllib.request; urllib.request.urlretrieve(sys.argv[1], '/tmp/gitleaks.tar.gz')" "$url" \
|
||||||
&& echo "${sha} /tmp/gitleaks.tar.gz" | sha256sum -c - \
|
&& echo "${GITLEAKS_SHA256} /tmp/gitleaks.tar.gz" | sha256sum -c - \
|
||||||
&& tar -xzf /tmp/gitleaks.tar.gz -C /usr/bin gitleaks \
|
&& tar -xzf /tmp/gitleaks.tar.gz -C /usr/bin gitleaks \
|
||||||
&& rm /tmp/gitleaks.tar.gz
|
&& rm /tmp/gitleaks.tar.gz
|
||||||
|
|
||||||
# Install bot_bottle as a proper package so entry-point scripts can use
|
# Project Python: addon + server modules + the init supervisor.
|
||||||
# `from bot_bottle.X import Y` absolute imports. A rename or a missing
|
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||||
# module is caught at pip-install time — not at container runtime.
|
# top-level siblings (absolute imports), matching the prior
|
||||||
COPY pyproject.toml /src/
|
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||||
COPY bot_bottle/ /src/bot_bottle/
|
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||||
RUN pip install --no-cache-dir /src/
|
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
||||||
|
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||||
# mitmdump -s requires a file path, not a module. Write a one-line shim that
|
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
|
||||||
# re-exports `addons` from the installed package; mitmdump finds it there.
|
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||||
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py
|
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||||
|
COPY bot_bottle/paths.py /app/paths.py
|
||||||
|
COPY bot_bottle/migrations.py /app/migrations.py
|
||||||
|
COPY bot_bottle/db_store.py /app/db_store.py
|
||||||
|
COPY bot_bottle/supervise_types.py /app/supervise_types.py
|
||||||
|
COPY bot_bottle/queue_store.py /app/queue_store.py
|
||||||
|
COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||||
|
COPY bot_bottle/store_manager.py /app/store_manager.py
|
||||||
|
COPY bot_bottle/supervise.py /app/supervise.py
|
||||||
|
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||||
|
COPY bot_bottle/gateway_init.py /app/gateway_init.py
|
||||||
|
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||||
RUN chmod +x /app/egress-entrypoint.sh
|
RUN chmod +x /app/egress-entrypoint.sh
|
||||||
|
|
||||||
@@ -117,8 +112,10 @@ RUN mkdir -p \
|
|||||||
# subset the bottle uses.
|
# subset the bottle uses.
|
||||||
EXPOSE 8888 9099 9418 9420 9100
|
EXPOSE 8888 9099 9418 9420 9100
|
||||||
|
|
||||||
|
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
||||||
|
# in-app same-dir import in supervise_server.py stays deterministic.
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||||
# propagation; no `exec` chain in the entrypoint itself.
|
# propagation; no `exec` chain in the entrypoint itself.
|
||||||
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
|
ENTRYPOINT ["python3", "/app/gateway_init.py"]
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ 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",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from abc import ABC, abstractmethod
|
|||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
@@ -54,9 +54,6 @@ from ..workspace import WorkspacePlan, workspace_plan
|
|||||||
from .print_util import print_multi, visible_agent_env_names
|
from .print_util import print_multi, visible_agent_env_names
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BottleSpec:
|
class BottleSpec:
|
||||||
@@ -587,63 +584,28 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
Not called by the launch path or the test suite."""
|
Not called by the launch path or the test suite."""
|
||||||
|
|
||||||
|
|
||||||
# _backends is None until the first call to _get_backends(), at which
|
# Import concrete backend classes AFTER the base types are defined, so
|
||||||
# point all three concrete backend classes are imported and instantiated.
|
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||||
# Keeping the imports out of module scope means that importing any
|
# via `from . import ...` without hitting a partially-initialized module.
|
||||||
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
|
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
# firecracker and macos-container implementations into memory.
|
from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
#
|
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
# Tests may replace _backends with a {name: fake} dict via patch.object;
|
|
||||||
# _get_backends() returns the current module-level value as-is when it
|
# Freezer is imported after the backend classes for the same reason:
|
||||||
# is not None, so test fakes take effect without triggering real imports.
|
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
||||||
_backends: dict[str, BottleBackend[Any, Any]] | None = None
|
# defined first.
|
||||||
|
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
|
|
||||||
|
|
||||||
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
|
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||||
"""Return the registry of all backend instances, loading lazily on first call."""
|
# over its own plan type. Concrete plan types are erased here because
|
||||||
global _backends # pylint: disable=global-statement
|
# the registry is selected at runtime and the CLI only needs the
|
||||||
if _backends is None:
|
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||||
from .docker import DockerBottleBackend
|
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
from .macos_container import MacosContainerBottleBackend
|
|
||||||
_backends = {
|
|
||||||
"docker": DockerBottleBackend(),
|
"docker": DockerBottleBackend(),
|
||||||
"firecracker": FirecrackerBottleBackend(),
|
"firecracker": FirecrackerBottleBackend(),
|
||||||
"macos-container": MacosContainerBottleBackend(),
|
"macos-container": MacosContainerBottleBackend(),
|
||||||
}
|
}
|
||||||
return _backends
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> Any:
|
|
||||||
"""Lazily surface concrete backend classes and freeze symbols at the
|
|
||||||
package level so existing `from bot_bottle.backend import X` and
|
|
||||||
`patch.object(backend_mod, X, ...)` call-sites keep working without
|
|
||||||
forcing an import of every backend at module-init time."""
|
|
||||||
if name == "DockerBottleBackend":
|
|
||||||
from .docker import DockerBottleBackend
|
|
||||||
globals()[name] = DockerBottleBackend
|
|
||||||
return DockerBottleBackend
|
|
||||||
if name == "FirecrackerBottleBackend":
|
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
globals()[name] = FirecrackerBottleBackend
|
|
||||||
return FirecrackerBottleBackend
|
|
||||||
if name == "MacosContainerBottleBackend":
|
|
||||||
from .macos_container import MacosContainerBottleBackend
|
|
||||||
globals()[name] = MacosContainerBottleBackend
|
|
||||||
return MacosContainerBottleBackend
|
|
||||||
if name == "CommitCancelled":
|
|
||||||
from .freeze import CommitCancelled
|
|
||||||
globals()[name] = CommitCancelled
|
|
||||||
return CommitCancelled
|
|
||||||
if name == "Freezer":
|
|
||||||
from .freeze import Freezer
|
|
||||||
globals()[name] = Freezer
|
|
||||||
return Freezer
|
|
||||||
if name == "get_freezer":
|
|
||||||
from .freeze import get_freezer
|
|
||||||
globals()[name] = get_freezer
|
|
||||||
return get_freezer
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_bottle_backend(
|
def get_bottle_backend(
|
||||||
@@ -661,11 +623,10 @@ def get_bottle_backend(
|
|||||||
Dies with a pointer at the known backends if the chosen name
|
Dies with a pointer at the known backends if the chosen name
|
||||||
isn't implemented."""
|
isn't implemented."""
|
||||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
||||||
backends = _get_backends()
|
if resolved not in _BACKENDS:
|
||||||
if resolved not in backends:
|
known = ", ".join(sorted(_BACKENDS))
|
||||||
known = ", ".join(sorted(backends))
|
|
||||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||||
return backends[resolved]
|
return _BACKENDS[resolved]
|
||||||
|
|
||||||
|
|
||||||
def _default_backend_name() -> str:
|
def _default_backend_name() -> str:
|
||||||
@@ -675,17 +636,16 @@ def _default_backend_name() -> str:
|
|||||||
# `firecracker` binary isn't installed yet: selecting it here routes
|
# `firecracker` binary isn't installed yet: selecting it here routes
|
||||||
# start through firecracker's preflight, which prints an install
|
# start through firecracker's preflight, which prints an install
|
||||||
# pointer, instead of silently falling back to docker.
|
# pointer, instead of silently falling back to docker.
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
if FirecrackerBottleBackend.is_host_capable():
|
if FirecrackerBottleBackend.is_host_capable():
|
||||||
return "firecracker"
|
return "firecracker"
|
||||||
return "docker"
|
return "docker"
|
||||||
|
|
||||||
|
|
||||||
def known_backend_names() -> tuple[str, ...]:
|
def known_backend_names() -> tuple[str, ...]:
|
||||||
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
|
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
||||||
argparse (`--backend` choices) and the dashboard's backend
|
argparse (`--backend` choices) and the dashboard's backend
|
||||||
picker."""
|
picker."""
|
||||||
return tuple(sorted(_get_backends()))
|
return tuple(sorted(_BACKENDS))
|
||||||
|
|
||||||
|
|
||||||
def has_backend(name: str) -> bool:
|
def has_backend(name: str) -> bool:
|
||||||
@@ -697,10 +657,9 @@ def has_backend(name: str) -> bool:
|
|||||||
|
|
||||||
Returns False for unknown names so callers can pass
|
Returns False for unknown names so callers can pass
|
||||||
arbitrary input without separate validation."""
|
arbitrary input without separate validation."""
|
||||||
backends = _get_backends()
|
if name not in _BACKENDS:
|
||||||
if name not in backends:
|
|
||||||
return False
|
return False
|
||||||
return backends[name].is_available()
|
return _BACKENDS[name].is_available()
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active_agents() -> list[ActiveAgent]:
|
def enumerate_active_agents() -> list[ActiveAgent]:
|
||||||
@@ -716,11 +675,10 @@ def enumerate_active_agents() -> list[ActiveAgent]:
|
|||||||
deterministic tiebreaker. Agents with missing metadata
|
deterministic tiebreaker. Agents with missing metadata
|
||||||
(`started_at == ""`) sort first."""
|
(`started_at == ""`) sort first."""
|
||||||
out: list[ActiveAgent] = []
|
out: list[ActiveAgent] = []
|
||||||
backends = _get_backends()
|
for name in known_backend_names():
|
||||||
for name in sorted(backends):
|
if not has_backend(name):
|
||||||
if not backends[name].is_available():
|
|
||||||
continue
|
continue
|
||||||
out.extend(backends[name].enumerate_active())
|
out.extend(_BACKENDS[name].enumerate_active())
|
||||||
out.sort(key=lambda a: (a.started_at, a.slug))
|
out.sort(key=lambda a: (a.started_at, a.slug))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -94,12 +94,6 @@ def provision_git_gate(
|
|||||||
transport.exec(["mkdir", "-p", "/etc/git-gate"])
|
transport.exec(["mkdir", "-p", "/etc/git-gate"])
|
||||||
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
|
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
|
||||||
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
|
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
|
||||||
# The access-hook is exec'd directly (not via `sh`), so it needs the x bit.
|
|
||||||
# Set it here rather than trusting the copy to carry the staged 0o700:
|
|
||||||
# `docker cp` preserves source mode, but the Apple `container cp` does not,
|
|
||||||
# landing the hook 0o644 → EACCES when the git-http handler tries to exec it.
|
|
||||||
# chmod on the gateway side is backend-neutral and fixes every transport.
|
|
||||||
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
|
||||||
creds = _creds_dir(bottle_id)
|
creds = _creds_dir(bottle_id)
|
||||||
transport.exec(["mkdir", "-p", creds])
|
transport.exec(["mkdir", "-p", creds])
|
||||||
for u in plan.upstreams:
|
for u in plan.upstreams:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Iterator
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
from ...docker_cmd import run_docker
|
from ...docker_cmd import run_docker
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
@@ -32,7 +32,12 @@ def container_name_candidates(base: str) -> Iterator[str]:
|
|||||||
def runsc_available() -> bool:
|
def runsc_available() -> bool:
|
||||||
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime
|
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime
|
||||||
registered. Called once per prepare; the result lives on the plan."""
|
registered. Called once per prepare; the result lives on the plan."""
|
||||||
r = run_docker(["docker", "info", "--format", "{{json .Runtimes}}"])
|
r = subprocess.run(
|
||||||
|
["docker", "info", "--format", "{{json .Runtimes}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
return r.returncode == 0 and "runsc" in r.stdout
|
return r.returncode == 0 and "runsc" in r.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -46,15 +51,20 @@ def require_docker() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def image_exists(ref: str) -> bool:
|
def image_exists(ref: str) -> bool:
|
||||||
return run_docker(["docker", "image", "inspect", ref]).returncode == 0
|
return _silent_run(["docker", "image", "inspect", ref]) == 0
|
||||||
|
|
||||||
|
|
||||||
def container_exists(name: str) -> bool:
|
def container_exists(name: str) -> bool:
|
||||||
"""Returns True if a container (running or stopped) with the given
|
"""Returns True if a container (running or stopped) with the given
|
||||||
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
|
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
|
||||||
matches don't false-positive."""
|
matches don't false-positive."""
|
||||||
result = run_docker(["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"])
|
result = subprocess.run(
|
||||||
return result.returncode == 0 and bool(result.stdout.strip())
|
["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
def force_remove_container(name: str) -> None:
|
def force_remove_container(name: str) -> None:
|
||||||
@@ -62,7 +72,12 @@ def force_remove_container(name: str) -> None:
|
|||||||
doesn't — and the rm itself is best-effort (errors swallowed) so
|
doesn't — and the rm itself is best-effort (errors swallowed) so
|
||||||
this is safe to register as a teardown callback."""
|
this is safe to register as a teardown callback."""
|
||||||
if container_exists(name):
|
if container_exists(name):
|
||||||
run_docker(["docker", "rm", "-f", name])
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def docker_exec_root(container: str, argv: list[str]) -> None:
|
def docker_exec_root(container: str, argv: list[str]) -> None:
|
||||||
@@ -190,10 +205,22 @@ def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
|||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
def commit_container(container_name: str, image_tag: str) -> None:
|
||||||
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
||||||
running container's filesystem state as a local Docker image."""
|
running container's filesystem state as a local Docker image."""
|
||||||
result = run_docker(["docker", "commit", container_name, image_tag])
|
result = subprocess.run(
|
||||||
|
["docker", "commit", container_name, image_tag],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
die(
|
die(
|
||||||
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
)
|
)
|
||||||
info(f"committed {container_name!r} → {image_tag!r}")
|
info(f"committed {container_name!r} → {image_tag!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _silent_run(cmd: Iterable[str]) -> int:
|
||||||
|
return subprocess.run(
|
||||||
|
list(cmd),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Shared wire-protocol constants for gateway-bundled modules.
|
|
||||||
|
|
||||||
Single source of truth for values that appear across the egress addon,
|
|
||||||
git-http backend, supervise server, and git-gate renderer. Importing
|
|
||||||
from this module instead of duplicating the literals means a rename is
|
|
||||||
a one-line change and is caught by the type checker at the import site."""
|
|
||||||
|
|
||||||
# App-layer identity token header. Delivered as proxy credentials
|
|
||||||
# (HTTPS_PROXY=http://<bottle_id>:<token>@gw) by launch; the egress
|
|
||||||
# addon reads and strips it, the supervise server and git-http backend
|
|
||||||
# read it for attribution, and none of them forward it upstream.
|
|
||||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
|
||||||
|
|
||||||
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
|
||||||
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
|
||||||
# git_http_backend, and the git http-backend CGI subprocess.
|
|
||||||
GIT_GATE_TIMEOUT_SECS = 15
|
|
||||||
@@ -23,9 +23,8 @@ 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 CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
from ...egress import 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:
|
||||||
@@ -119,6 +118,7 @@ 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,24 +180,13 @@ 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 or forward_host_credentials) else "",
|
auth_scheme="Bearer" if auth_token else "",
|
||||||
token_ref=cred_token_ref,
|
token_ref=auth_token,
|
||||||
),)
|
),)
|
||||||
hidden_env_names: frozenset[str] = frozenset()
|
hidden_env_names: frozenset[str] = frozenset()
|
||||||
if auth_token or forward_host_credentials:
|
if auth_token:
|
||||||
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"})
|
||||||
|
|
||||||
@@ -219,7 +208,6 @@ 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:
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
"""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",
|
|
||||||
]
|
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
Pure Python, no mitmproxy dependency. Each detector is a module-level
|
Pure Python, no mitmproxy dependency. Each detector is a module-level
|
||||||
function returning `ScanResult | None`.
|
function returning `ScanResult | None`.
|
||||||
|
|
||||||
Available in the gateway via the installed `bot_bottle` package
|
Ships flat into the gateway image alongside
|
||||||
(see `Dockerfile.gateway`).
|
`egress_addon_core.py` — both this file and the package source use
|
||||||
|
the same try/except import shim pattern.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,6 +20,9 @@ from math import log2
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from urllib.parse import quote as url_quote
|
from urllib.parse import quote as url_quote
|
||||||
|
|
||||||
|
try:
|
||||||
|
from egress_addon_core import ScanResult # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from .egress_addon_core import ScanResult
|
from .egress_addon_core import ScanResult
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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"
|
||||||
|
|
||||||
@@ -401,7 +400,6 @@ 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",
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import typing
|
|||||||
|
|
||||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||||
|
|
||||||
from bot_bottle.constants import IDENTITY_HEADER
|
from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
||||||
from bot_bottle.dlp_detectors import redact_tokens, strip_crlf
|
|
||||||
from bot_bottle.egress_addon_core import (
|
|
||||||
LOG_BLOCKS,
|
LOG_BLOCKS,
|
||||||
LOG_FULL,
|
LOG_FULL,
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
@@ -40,7 +38,23 @@ from bot_bottle.egress_addon_core import (
|
|||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
from bot_bottle import supervise as _sv
|
|
||||||
|
try:
|
||||||
|
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
|
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
|
||||||
|
redact_tokens,
|
||||||
|
strip_crlf,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import supervise as _sv # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
|
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from policy_resolver import PolicyResolver # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from bot_bottle.policy_resolver import PolicyResolver
|
from bot_bottle.policy_resolver import PolicyResolver
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +66,13 @@ INTROSPECT_HOST = "_egress.local"
|
|||||||
# back to — so an unset value is a fatal misconfiguration (see __init__).
|
# back to — so an unset value is a fatal misconfiguration (see __init__).
|
||||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
|
# App-layer identity token. Delivered as proxy credentials
|
||||||
|
# (`HTTPS_PROXY=http://<bottle_id>:<token>@gw`): clients honor it as part of
|
||||||
|
# the proxy protocol without app changes, and the addon reads + strips it so
|
||||||
|
# it never leaks upstream. The legacy `x-bot-bottle-identity` request header
|
||||||
|
# is still stripped defensively (git-http uses that header on its own port).
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
# Per-flow key under which `request()` stashes the resolved (Config, supervise
|
# Per-flow key under which `request()` stashes the resolved (Config, supervise
|
||||||
# slug, env) so the later `response()` and `websocket_message()` hooks scan
|
# slug, env) so the later `response()` and `websocket_message()` hooks scan
|
||||||
# against the *calling bottle's* policy — the same one the request was decided
|
# against the *calling bottle's* policy — the same one the request was decided
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ exercise the parse + decision functions without depending on the
|
|||||||
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
|
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
|
||||||
container.
|
container.
|
||||||
|
|
||||||
Imports: stdlib + sibling package modules (`yaml_subset`,
|
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
|
||||||
`egress_dlp_config`). Available in the gateway via the installed
|
ships flat into the gateway image alongside this file —
|
||||||
`bot_bottle` package (see `Dockerfile.gateway`)."""
|
see `Dockerfile.gateway`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,10 +16,26 @@ import re
|
|||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
|
|
||||||
# DLP detector-config parsing lives in a sibling module. Re-exported below
|
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
||||||
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working.
|
# into the gateway — see Dockerfile.gateway). Re-exported below so existing
|
||||||
|
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||||
|
try:
|
||||||
|
from egress_dlp_config import ( # type: ignore[import-not-found]
|
||||||
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
|
INBOUND_DETECTOR_NAMES,
|
||||||
|
ON_MATCH_BLOCK,
|
||||||
|
ON_MATCH_REDACT,
|
||||||
|
ON_MATCH_SUPERVISE,
|
||||||
|
OUTBOUND_DETECTOR_NAMES,
|
||||||
|
OUTBOUND_ON_MATCH_VALUES,
|
||||||
|
parse_dlp_block,
|
||||||
|
)
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from .egress_dlp_config import (
|
from .egress_dlp_config import (
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
INBOUND_DETECTOR_NAMES,
|
INBOUND_DETECTOR_NAMES,
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
|||||||
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||||
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
||||||
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||||
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.git_http_backend")),
|
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
|
||||||
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.supervise_server")),
|
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -112,10 +112,8 @@ class GitGate(ABC):
|
|||||||
access_hook = stage_dir / "git_gate_access_hook.sh"
|
access_hook = stage_dir / "git_gate_access_hook.sh"
|
||||||
access_hook.write_text(git_gate_render_access_hook())
|
access_hook.write_text(git_gate_render_access_hook())
|
||||||
# 0o700 (not 0o600): git daemon execs --access-hook directly,
|
# 0o700 (not 0o600): git daemon execs --access-hook directly,
|
||||||
# not via `sh`, so the script needs the x bit. The gateway copy
|
# not via `sh`, so the script needs the x bit. docker cp
|
||||||
# does not necessarily preserve this mode (`docker cp` does, the
|
# preserves source mode into the container.
|
||||||
# Apple `container cp` does not), so provision_git_gate re-applies
|
|
||||||
# +x on the gateway side — see backend/docker/gateway_provision.py.
|
|
||||||
access_hook.chmod(0o700)
|
access_hook.chmod(0o700)
|
||||||
upstreams_with_files: list[GitGateUpstream] = []
|
upstreams_with_files: list[GitGateUpstream] = []
|
||||||
for u in upstreams:
|
for u in upstreams:
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ import shlex
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
|
||||||
from .manifest import ManifestBottle, ManifestGitEntry
|
from .manifest import ManifestBottle, ManifestGitEntry
|
||||||
|
|
||||||
# Short network alias for git-gate inside the gateway. The
|
# Short network alias for git-gate inside the gateway. The
|
||||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||||
GIT_GATE_HOSTNAME = "git-gate"
|
GIT_GATE_HOSTNAME = "git-gate"
|
||||||
|
# App-layer identity token header the agent's git sends to git-http and the
|
||||||
|
# gateway validates (mirrors egress_addon / git_http_backend IDENTITY_HEADER).
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||||
|
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
||||||
|
# git_http_backend, and the git http-backend CGI subprocess.
|
||||||
|
GIT_GATE_TIMEOUT_SECS = 15
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
# policy_resolver ships flat alongside this file in the gateway
|
||||||
|
# image (see Dockerfile.gateway); the bot_bottle.* fallback is the
|
||||||
|
# host-side / test path. Mirrors egress_addon's import shape.
|
||||||
|
try:
|
||||||
|
from policy_resolver import ( # type: ignore[import-not-found]
|
||||||
|
PolicyResolveError,
|
||||||
|
PolicyResolver,
|
||||||
|
)
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +46,12 @@ DEFAULT_PORT = 9420
|
|||||||
# repo-root fallback.
|
# repo-root fallback.
|
||||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
|
# App-layer identity token (defense-in-depth over the source-IP invariant);
|
||||||
|
# the agent injects it, the backend reads it for attribution and never
|
||||||
|
# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER
|
||||||
|
# (duplicated, not imported: egress_addon pulls in mitmproxy).
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
# The base under which each bottle's `<bottle_id>` repo namespace is nested.
|
# The base under which each bottle's `<bottle_id>` repo namespace is nested.
|
||||||
DEFAULT_REPO_ROOT = "/git"
|
DEFAULT_REPO_ROOT = "/git"
|
||||||
|
|
||||||
@@ -75,6 +89,13 @@ def resolve_sandbox_root(
|
|||||||
return None # bottle_id tried to escape the root → deny
|
return None # bottle_id tried to escape the root → deny
|
||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
|
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
|
||||||
|
# imported: this module ships as a flat top-level sibling in the gateway
|
||||||
|
# bundle image (see Dockerfile.gateway), not as part of the bot_bottle
|
||||||
|
# package, so `bot_bottle.git_gate` and its dependency chain aren't
|
||||||
|
# available at runtime.
|
||||||
|
GIT_GATE_TIMEOUT_SECS = 15
|
||||||
|
|
||||||
# Bound memory use while still allowing ordinary git push packfiles.
|
# Bound memory use while still allowing ordinary git push packfiles.
|
||||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||||
|
|
||||||
@@ -115,24 +136,12 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
|
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
|
||||||
)
|
)
|
||||||
peer = self.client_address[0]
|
peer = self.client_address[0]
|
||||||
try:
|
|
||||||
hook = subprocess.run(
|
hook = subprocess.run(
|
||||||
[hook_path, "upload-pack", str(repo_dir), peer, peer],
|
[hook_path, "upload-pack", str(repo_dir), peer, peer],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
|
||||||
# The access-hook couldn't be run (missing, not executable,
|
|
||||||
# timed out, …). Fail closed with a real HTTP error rather
|
|
||||||
# than letting the exception kill the handler thread — an
|
|
||||||
# unhandled exception closes the socket with no response, which
|
|
||||||
# the client sees as an opaque "empty reply from server".
|
|
||||||
self.log_message(
|
|
||||||
"access-hook could not run for %s: %s", parsed.path, exc,
|
|
||||||
)
|
|
||||||
self.send_error(503, "git-gate access-hook unavailable")
|
|
||||||
return
|
|
||||||
if hook.returncode != 0:
|
if hook.returncode != 0:
|
||||||
detail = (hook.stderr or hook.stdout).decode(
|
detail = (hook.stderr or hook.stdout).decode(
|
||||||
"utf-8", errors="replace",
|
"utf-8", errors="replace",
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ 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 provider auth token into
|
`forward_host_credentials` forwards the host Codex auth token into
|
||||||
the egress sidecar (Codex and Claude). For Codex this reads
|
the egress daemon (Codex only).
|
||||||
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
@@ -93,15 +92,10 @@ 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 not in {"codex", "claude"}:
|
if forward_host_credentials and template != "codex":
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
"is only supported for templates 'codex' and 'claude'"
|
"is currently only supported for template 'codex'"
|
||||||
)
|
|
||||||
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(
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ closed too rather than silently serving stale or empty policy.
|
|||||||
|
|
||||||
The resolved value is the policy blob the orchestrator stores verbatim; the
|
The resolved value is the policy blob the orchestrator stores verbatim; the
|
||||||
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
||||||
stdlib-only and free of bot-bottle imports.
|
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
|
||||||
|
the gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from abc import ABC
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
from .supervise_types import (
|
from .supervise_types import (
|
||||||
ACTION_OPERATOR_EDIT,
|
ACTION_OPERATOR_EDIT,
|
||||||
AuditEntry,
|
AuditEntry,
|
||||||
@@ -53,6 +54,23 @@ from .supervise_types import (
|
|||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_LIST_EGRESS_ROUTES,
|
TOOL_LIST_EGRESS_ROUTES,
|
||||||
)
|
)
|
||||||
|
except ImportError:
|
||||||
|
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
||||||
|
ACTION_OPERATOR_EDIT,
|
||||||
|
AuditEntry,
|
||||||
|
Proposal,
|
||||||
|
Response,
|
||||||
|
STATUSES,
|
||||||
|
STATUS_APPROVED,
|
||||||
|
STATUS_MODIFIED,
|
||||||
|
STATUS_REJECTED,
|
||||||
|
TOOLS,
|
||||||
|
TOOL_EGRESS_ALLOW,
|
||||||
|
TOOL_EGRESS_BLOCK,
|
||||||
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
|
TOOL_LIST_EGRESS_ROUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
|||||||
|
|
||||||
Everything else returns JSON-RPC error -32601 (method not found).
|
Everything else returns JSON-RPC error -32601 (method not found).
|
||||||
|
|
||||||
The Dockerfile copies this script to /app/supervise_server.py and installs
|
Stdlib-only. The Dockerfile copies this file + bot_bottle/supervise.py
|
||||||
the bot_bottle package so its `from bot_bottle.*` imports resolve.
|
into the image; the server imports `supervise` for the queue / Proposal
|
||||||
|
plumbing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -41,18 +42,29 @@ import time
|
|||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from bot_bottle.constants import IDENTITY_HEADER
|
try:
|
||||||
from bot_bottle.egress_addon_core import (
|
# Same-directory imports inside the bundle container; these files are
|
||||||
|
# COPYed flat under /app by Dockerfile.gateway.
|
||||||
|
from egress_addon_core import (
|
||||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||||
)
|
)
|
||||||
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
from policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
from bot_bottle import supervise as _sv
|
import supervise as _sv
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
# Package imports for host-side tests and tooling.
|
||||||
|
from .egress_addon_core import (
|
||||||
|
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||||
|
)
|
||||||
|
from .policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
|
from . import supervise as _sv
|
||||||
|
|
||||||
|
|
||||||
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
# App-layer identity token header (mirrors egress_addon / git_http_backend).
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
SERVER_NAME = "bot-bottle-supervise"
|
SERVER_NAME = "bot-bottle-supervise"
|
||||||
SERVER_VERSION = "0.1.0"
|
SERVER_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
# 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,8 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68"]
|
|
||||||
build-backend = "setuptools.backends.legacy:build"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "bot-bottle"
|
|
||||||
version = "0.0.0"
|
|
||||||
requires-python = ">=3.11"
|
|
||||||
@@ -9,15 +9,11 @@ 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 (
|
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||||
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _jwt(exp: int) -> str:
|
def _jwt(exp: int) -> str:
|
||||||
@@ -296,67 +292,6 @@ 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(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(backend_mod, "_BACKENDS", {
|
||||||
"macos-container": _FakeBackend(),
|
"macos-container": _FakeBackend(),
|
||||||
"docker": _FakeBackend(),
|
"docker": _FakeBackend(),
|
||||||
}):
|
}):
|
||||||
@@ -61,7 +61,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(backend_mod, "_BACKENDS", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
}):
|
}):
|
||||||
@@ -83,7 +83,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: True)), \
|
"is_host_capable", classmethod(lambda cls: True)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(backend_mod, "_BACKENDS", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"firecracker": _FakeBackend("firecracker", False),
|
"firecracker": _FakeBackend("firecracker", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
@@ -133,7 +133,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
||||||
):
|
):
|
||||||
self.assertEqual([a, b], enumerate_active_agents())
|
self.assertEqual([a, b], enumerate_active_agents())
|
||||||
@@ -167,7 +167,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{
|
{
|
||||||
"docker": _FakeBackend([newer, tie_b]),
|
"docker": _FakeBackend([newer, tie_b]),
|
||||||
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
||||||
@@ -187,7 +187,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
||||||
):
|
):
|
||||||
self.assertEqual([], enumerate_active_agents())
|
self.assertEqual([], enumerate_active_agents())
|
||||||
@@ -218,7 +218,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{
|
{
|
||||||
"docker": _FakeBackend([present], available=True),
|
"docker": _FakeBackend([present], available=True),
|
||||||
"firecracker": _FakeBackend([hidden], available=False),
|
"firecracker": _FakeBackend([hidden], available=False),
|
||||||
@@ -234,7 +234,7 @@ class TestHasBackend(unittest.TestCase):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends", {"docker": _FakeBackend()},
|
backend_mod, "_BACKENDS", {"docker": _FakeBackend()},
|
||||||
):
|
):
|
||||||
from bot_bottle.backend import has_backend
|
from bot_bottle.backend import has_backend
|
||||||
self.assertFalse(has_backend("docker"))
|
self.assertFalse(has_backend("docker"))
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -29,7 +29,7 @@ def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
|
|||||||
class TestCommitContainer(unittest.TestCase):
|
class TestCommitContainer(unittest.TestCase):
|
||||||
def test_runs_docker_commit(self):
|
def test_runs_docker_commit(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod, "run_docker", return_value=_ok(),
|
docker_mod.subprocess, "run", return_value=_ok(),
|
||||||
) as run, patch.object(docker_mod, "info"):
|
) as run, patch.object(docker_mod, "info"):
|
||||||
docker_mod.commit_container(
|
docker_mod.commit_container(
|
||||||
"bot-bottle-dev-abc12",
|
"bot-bottle-dev-abc12",
|
||||||
@@ -47,7 +47,7 @@ class TestCommitContainer(unittest.TestCase):
|
|||||||
|
|
||||||
def test_dies_on_docker_commit_failure(self):
|
def test_dies_on_docker_commit_failure(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod, "run_docker", return_value=_fail("No such container"),
|
docker_mod.subprocess, "run", return_value=_fail("No such container"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
docker_mod, "die", side_effect=SystemExit("die"),
|
docker_mod, "die", side_effect=SystemExit("die"),
|
||||||
) as die:
|
) as die:
|
||||||
@@ -58,7 +58,7 @@ class TestCommitContainer(unittest.TestCase):
|
|||||||
|
|
||||||
def test_die_message_includes_image_tag(self):
|
def test_die_message_includes_image_tag(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod, "run_docker", return_value=_fail("boom"),
|
docker_mod.subprocess, "run", return_value=_fail("boom"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
docker_mod, "die", side_effect=SystemExit("die"),
|
docker_mod, "die", side_effect=SystemExit("die"),
|
||||||
) as die:
|
) as die:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# mitmproxy stub — must run before importing egress_addon
|
# Gateway-import shims — must run before importing egress_addon
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _ensure_shims() -> None:
|
def _ensure_shims() -> None:
|
||||||
@@ -32,6 +32,9 @@ def _ensure_shims() -> None:
|
|||||||
setattr(_mm, "http", _mh)
|
setattr(_mm, "http", _mh)
|
||||||
sys.modules["mitmproxy"] = _mm
|
sys.modules["mitmproxy"] = _mm
|
||||||
sys.modules["mitmproxy.http"] = _mh
|
sys.modules["mitmproxy.http"] = _mh
|
||||||
|
if "egress_addon_core" not in sys.modules:
|
||||||
|
import bot_bottle.egress_addon_core as _core
|
||||||
|
sys.modules["egress_addon_core"] = _core
|
||||||
|
|
||||||
|
|
||||||
_ensure_shims()
|
_ensure_shims()
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ def _ensure_shims() -> None:
|
|||||||
setattr(mh, "Response", _Response)
|
setattr(mh, "Response", _Response)
|
||||||
if not hasattr(mh, "HTTPFlow"):
|
if not hasattr(mh, "HTTPFlow"):
|
||||||
setattr(mh, "HTTPFlow", object)
|
setattr(mh, "HTTPFlow", object)
|
||||||
|
if "egress_addon_core" not in sys.modules:
|
||||||
|
import bot_bottle.egress_addon_core as _core
|
||||||
|
sys.modules["egress_addon_core"] = _core
|
||||||
|
|
||||||
|
|
||||||
_ensure_shims()
|
_ensure_shims()
|
||||||
|
|||||||
@@ -69,18 +69,6 @@ class TestProvisionGitGate(unittest.TestCase):
|
|||||||
self.assertEqual(1, len(exec_scripts))
|
self.assertEqual(1, len(exec_scripts))
|
||||||
self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1])
|
self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1])
|
||||||
|
|
||||||
def test_makes_access_hook_executable_on_the_gateway(self) -> None:
|
|
||||||
# Regression: the access-hook is exec'd directly, so it needs the x
|
|
||||||
# bit. The copy alone can't be trusted to carry the staged 0o700
|
|
||||||
# (`docker cp` preserves mode, the Apple `container cp` does not),
|
|
||||||
# so provisioning must re-apply +x on the gateway side.
|
|
||||||
calls: list[list[str]] = []
|
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
|
||||||
provision_git_gate(DockerGatewayTransport("gw"), "bottle1", _plan(_up("foo")))
|
|
||||||
self.assertIn(
|
|
||||||
["docker", "exec", "gw", "chmod", "+x", "/etc/git-gate/access-hook"], calls,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_omits_known_hosts_copy_when_absent(self) -> None:
|
def test_omits_known_hosts_copy_when_absent(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
with patch(_RUN, side_effect=_recorder(calls)):
|
||||||
|
|||||||
@@ -375,49 +375,6 @@ class TestGitHttpBackend(unittest.TestCase):
|
|||||||
self.assertIn("access-hook denied", logged)
|
self.assertIn("access-hook denied", logged)
|
||||||
self.assertIn("exit=2", logged)
|
self.assertIn("exit=2", logged)
|
||||||
|
|
||||||
def test_access_hook_that_cannot_run_fails_closed_503(self):
|
|
||||||
"""Regression: when the access-hook can't be exec'd (missing / not
|
|
||||||
executable — a PermissionError from subprocess.run), the handler must
|
|
||||||
fail closed with a real HTTP status instead of letting the exception
|
|
||||||
kill the thread, which closes the socket with no response and the
|
|
||||||
client sees an opaque "empty reply from server"."""
|
|
||||||
from http.server import ThreadingHTTPServer
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
(root / _BID / "repo.git").mkdir(parents=True)
|
|
||||||
old_root = os.environ.get("GIT_PROJECT_ROOT")
|
|
||||||
os.environ["GIT_PROJECT_ROOT"] = str(root)
|
|
||||||
self.addCleanup(self._restore_env, old_root)
|
|
||||||
|
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
|
|
||||||
server.policy_resolver = _FixedResolver(_BID) # type: ignore[attr-defined]
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
self.addCleanup(server.shutdown)
|
|
||||||
self.addCleanup(server.server_close)
|
|
||||||
|
|
||||||
with mock.patch(
|
|
||||||
"bot_bottle.git_http_backend.subprocess.run",
|
|
||||||
side_effect=PermissionError(13, "Permission denied"),
|
|
||||||
):
|
|
||||||
buf = io.StringIO()
|
|
||||||
with mock.patch.object(sys, "stdout", buf):
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"http://127.0.0.1:{server.server_port}"
|
|
||||||
"/repo.git/info/refs?service=git-upload-pack",
|
|
||||||
method="GET",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
self.fail("expected HTTPError 503")
|
|
||||||
except urllib.error.HTTPError as e: # type: ignore
|
|
||||||
self.assertEqual(503, e.code)
|
|
||||||
|
|
||||||
self.assertIn("access-hook could not run", buf.getvalue())
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _restore_env(value: str | None) -> None:
|
def _restore_env(value: str | None) -> None:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -80,19 +80,11 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
|||||||
"forward_host_credentials": "yes",
|
"forward_host_credentials": "yes",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_forward_host_credentials_allowed_for_claude(self):
|
def test_forward_host_credentials_rejected_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,23 +86,11 @@ class TestAgentProviderValidation(unittest.TestCase):
|
|||||||
"b", {"forward_host_credentials": True, "template": "weird"}
|
"b", {"forward_host_credentials": True, "template": "weird"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_forward_creds_pi_template_rejected(self) -> None:
|
def test_forward_creds_non_codex_template(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"})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -12,9 +13,15 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
|
|
||||||
from bot_bottle import supervise as _sv
|
|
||||||
from bot_bottle import queue_store as _qs
|
# The server module loads `supervise` via same-directory import inside
|
||||||
from bot_bottle import audit_store as _as
|
# the container (Dockerfile.supervise WORKDIRs into /app). For tests
|
||||||
|
# we mirror that by injecting bot_bottle/ onto sys.path under the
|
||||||
|
# bare name `supervise`.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bottle"))
|
||||||
|
import supervise as _sv # noqa: E402 # type: ignore
|
||||||
|
import queue_store as _qs # noqa: E402 # type: ignore
|
||||||
|
import audit_store as _as # noqa: E402 # type: ignore
|
||||||
|
|
||||||
from bot_bottle import supervise_server # noqa: E402
|
from bot_bottle import supervise_server # noqa: E402
|
||||||
from bot_bottle.supervise_server import (
|
from bot_bottle.supervise_server import (
|
||||||
|
|||||||
Reference in New Issue
Block a user