Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f72785536a | |||
| aed686d85d | |||
| 410c19aaaf | |||
| f0ba399f17 | |||
| 8b442b8718 | |||
| 5eb6c8d99b | |||
| 4f10b810d4 | |||
| d3c4fc0fd4 | |||
| 232dfdf37a | |||
| 9a0dd821ef | |||
| 5ad3449e3b |
@@ -22,10 +22,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: canaries are stdlib unittest on the image's
|
||||||
uses: actions/setup-python@v5
|
# system Python 3.12 (older act_runner mishandles setup-python's PATH).
|
||||||
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,15 +13,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
uses: actions/setup-python@v4
|
# and older act_runner engines mishandle setup-python's PATH. Install
|
||||||
with:
|
# into the ephemeral job container's system Python — the pylint/pyright
|
||||||
python-version: "3.12"
|
# console scripts land on /usr/local/bin (on PATH) so the steps below
|
||||||
|
# still resolve. --break-system-packages is safe: the container is
|
||||||
|
# disposable.
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: |
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -37,11 +37,8 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the inline script is stdlib-only on the
|
||||||
uses: actions/setup-python@v5
|
# image's system Python 3.12 (older act_runner mishandles its PATH).
|
||||||
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]"
|
||||||
|
|||||||
+14
-17
@@ -34,13 +34,13 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
uses: actions/setup-python@v5
|
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||||
with:
|
# lands in one interpreter, `python3` resolves to another). Install
|
||||||
python-version: "3.12"
|
# straight into the ephemeral job container's system Python —
|
||||||
|
# --break-system-packages is safe because the container is disposable.
|
||||||
- name: Install dev requirements
|
- name: Install dev requirements
|
||||||
run: python3 -m pip install -r requirements-dev.txt
|
run: python3 -m pip install --break-system-packages -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,11 +54,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python (see the note in the `unit` job); the
|
||||||
uses: actions/setup-python@v5
|
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Show environment
|
- name: Show environment
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
python3 --version
|
||||||
@@ -88,13 +85,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
uses: actions/setup-python@v5
|
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||||
with:
|
# lands in one interpreter, `python3` resolves to another). Install
|
||||||
python-version: "3.12"
|
# straight into the ephemeral job container's system Python —
|
||||||
|
# --break-system-packages is safe because the container is disposable.
|
||||||
- name: Install dev requirements
|
- name: Install dev requirements
|
||||||
run: python3 -m pip install -r requirements-dev.txt
|
run: python3 -m pip install --break-system-packages -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,21 +20,18 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image ships Python 3.12 and older
|
||||||
uses: actions/setup-python@v4
|
# act_runner engines mishandle setup-python's PATH. Install into the
|
||||||
with:
|
# ephemeral job container's system Python (--break-system-packages is
|
||||||
python-version: '3.12'
|
# safe because the container is disposable).
|
||||||
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: |
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
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: |
|
||||||
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||||
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python3 -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%"
|
||||||
|
|
||||||
@@ -45,7 +42,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=$(python -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python3 -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%"
|
||||||
|
|
||||||
|
|||||||
+16
-27
@@ -16,10 +16,12 @@
|
|||||||
# Layout:
|
# Layout:
|
||||||
#
|
#
|
||||||
# /usr/bin/gitleaks gitleaks binary
|
# /usr/bin/gitleaks gitleaks binary
|
||||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
# /app/egress_addon.py mitmproxy addon entry point
|
||||||
# /app/egress-entrypoint.sh mitmdump launcher
|
# /app/egress-entrypoint.sh mitmdump launcher
|
||||||
# /app/supervise_server.py + .py supervise MCP server
|
# /usr/local/lib/python*/bot_bottle/ installed package (all daemons + shared modules)
|
||||||
# /app/gateway_init.py PID 1 supervisor
|
# /app/egress_addon.py one-line shim: re-exports addons from package
|
||||||
|
# (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
|
||||||
@@ -87,27 +89,16 @@ RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}" \
|
|||||||
&& 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
|
||||||
|
|
||||||
# Project Python: addon + server modules + the init supervisor.
|
# Install bot_bottle as a proper package so entry-point scripts can use
|
||||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
# `from bot_bottle.X import Y` absolute imports. A rename or a missing
|
||||||
# top-level siblings (absolute imports), matching the prior
|
# module is caught at pip-install time — not at container runtime.
|
||||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
COPY pyproject.toml /src/
|
||||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
COPY bot_bottle/ /src/bot_bottle/
|
||||||
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
RUN pip install --no-cache-dir /src/
|
||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
|
||||||
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
|
# mitmdump -s requires a file path, not a module. Write a one-line shim that
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
# re-exports `addons` from the installed package; mitmdump finds it there.
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.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
|
||||||
|
|
||||||
@@ -126,10 +117,8 @@ 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", "/app/gateway_init.py"]
|
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
|
||||||
|
|||||||
@@ -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,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
|||||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||||
|
|
||||||
|
# Host that egress injects the host Claude bearer on when Claude
|
||||||
|
# forward_host_credentials is enabled.
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
|
||||||
PromptMode = Literal[
|
PromptMode = Literal[
|
||||||
"append_file",
|
"append_file",
|
||||||
"read_prompt_file",
|
"read_prompt_file",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""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,8 +23,9 @@ from ...agent_provider import (
|
|||||||
provider_startup_args,
|
provider_startup_args,
|
||||||
)
|
)
|
||||||
from ...backend.docker import util as docker_mod
|
from ...backend.docker import util as docker_mod
|
||||||
from ...egress import EgressRoute
|
from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from .claude_auth import claude_host_access_token
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -118,7 +119,6 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
color: str = "",
|
color: str = "",
|
||||||
provider_settings: dict[str, object] | None = None,
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
del forward_host_credentials, host_env
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
resolved_guest_env = dict(guest_env or {})
|
||||||
startup_args = provider_startup_args(provider_settings)
|
startup_args = provider_startup_args(provider_settings)
|
||||||
guest_home = self.guest_home
|
guest_home = self.guest_home
|
||||||
@@ -180,13 +180,24 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
claude_settings,
|
claude_settings,
|
||||||
f"{guest_home}/.claude/settings.json",
|
f"{guest_home}/.claude/settings.json",
|
||||||
))
|
))
|
||||||
|
provisioned_env: dict[str, str] = {}
|
||||||
|
if forward_host_credentials:
|
||||||
|
_host_env = host_env or dict(os.environ)
|
||||||
|
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
|
||||||
|
claude_host_access_token(_host_env)
|
||||||
|
)
|
||||||
|
|
||||||
|
cred_token_ref = (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
|
||||||
|
else auth_token
|
||||||
|
)
|
||||||
egress_routes = (EgressRoute(
|
egress_routes = (EgressRoute(
|
||||||
host="api.anthropic.com",
|
host="api.anthropic.com",
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
|
||||||
token_ref=auth_token,
|
token_ref=cred_token_ref,
|
||||||
),)
|
),)
|
||||||
hidden_env_names: frozenset[str] = frozenset()
|
hidden_env_names: frozenset[str] = frozenset()
|
||||||
if auth_token:
|
if auth_token or forward_host_credentials:
|
||||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||||
|
|
||||||
@@ -208,6 +219,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
egress_routes=egress_routes,
|
egress_routes=egress_routes,
|
||||||
hidden_env_names=hidden_env_names,
|
hidden_env_names=hidden_env_names,
|
||||||
|
provisioned_env=provisioned_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Host Claude auth helpers.
|
||||||
|
|
||||||
|
Reads the host's Claude Code credentials and returns only the access
|
||||||
|
token needed by egress. Does not expose refresh tokens or raw payloads.
|
||||||
|
|
||||||
|
Credential storage by platform:
|
||||||
|
Linux — ~/.claude/.credentials.json
|
||||||
|
macOS — macOS Keychain, service "Claude Code-credentials"
|
||||||
|
(file path is tried first; Keychain is the fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die
|
||||||
|
|
||||||
|
|
||||||
|
_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
||||||
|
env = os.environ if host_env is None else host_env
|
||||||
|
home = env.get("HOME")
|
||||||
|
if home:
|
||||||
|
return Path(home) / ".claude" / ".credentials.json"
|
||||||
|
return Path.home() / ".claude" / ".credentials.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_keychain() -> dict[str, object] | None:
|
||||||
|
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = json.loads(result.stdout.strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def claude_host_access_token(
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
path = claude_auth_path(host_env)
|
||||||
|
raw: dict[str, object] | None = None
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
die(f"claude host credentials: {path} must contain a JSON object")
|
||||||
|
else:
|
||||||
|
raw = _read_keychain()
|
||||||
|
if raw is None:
|
||||||
|
die(
|
||||||
|
f"claude host credentials: auth file missing at {path} and "
|
||||||
|
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth = raw.get("claudeAiOauth")
|
||||||
|
if not isinstance(oauth, dict):
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth is missing from credentials. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = oauth.get("accessToken")
|
||||||
|
if not isinstance(access_token, str) or not access_token:
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
# expiresAt is in milliseconds
|
||||||
|
expires_at = oauth.get("expiresAt")
|
||||||
|
if isinstance(expires_at, (int, float)):
|
||||||
|
check_now = now or datetime.now(timezone.utc)
|
||||||
|
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
|
||||||
|
if exp_dt <= check_now:
|
||||||
|
die(
|
||||||
|
"claude host credentials: host Claude access token is expired. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"claude_auth_path",
|
||||||
|
"claude_host_access_token",
|
||||||
|
]
|
||||||
@@ -3,9 +3,8 @@
|
|||||||
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`.
|
||||||
|
|
||||||
Ships flat into the gateway image alongside
|
Available in the gateway via the installed `bot_bottle` package
|
||||||
`egress_addon_core.py` — both this file and the package source use
|
(see `Dockerfile.gateway`).
|
||||||
the same try/except import shim pattern.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,10 +19,7 @@ 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
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
from .manifest import ManifestBottle
|
from .manifest import ManifestBottle
|
||||||
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
|
||||||
|
|
||||||
EGRESS_HOSTNAME = "egress"
|
EGRESS_HOSTNAME = "egress"
|
||||||
|
|
||||||
@@ -400,6 +401,7 @@ class Egress(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
"EGRESS_ROUTES_FILENAME",
|
"EGRESS_ROUTES_FILENAME",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ 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 egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
|
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,
|
||||||
@@ -38,24 +40,8 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
|||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
try:
|
from bot_bottle.policy_resolver import PolicyResolver
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
INTROSPECT_HOST = "_egress.local"
|
INTROSPECT_HOST = "_egress.local"
|
||||||
@@ -66,13 +52,6 @@ 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 + `yaml_subset` (which is itself stdlib-only and
|
Imports: stdlib + sibling package modules (`yaml_subset`,
|
||||||
ships flat into the gateway image alongside this file —
|
`egress_dlp_config`). Available in the gateway via the installed
|
||||||
see `Dockerfile.gateway`)."""
|
`bot_bottle` package (see `Dockerfile.gateway`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,36 +16,20 @@ import re
|
|||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
try:
|
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
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
|
|
||||||
|
|
||||||
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
# DLP detector-config parsing lives in a sibling module. Re-exported below
|
||||||
# into the gateway — see Dockerfile.gateway). Re-exported below so existing
|
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||||
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
from .egress_dlp_config import (
|
||||||
try:
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
from egress_dlp_config import ( # type: ignore[import-not-found]
|
INBOUND_DETECTOR_NAMES,
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
ON_MATCH_BLOCK,
|
||||||
INBOUND_DETECTOR_NAMES,
|
ON_MATCH_REDACT,
|
||||||
ON_MATCH_BLOCK,
|
ON_MATCH_SUPERVISE,
|
||||||
ON_MATCH_REDACT,
|
OUTBOUND_DETECTOR_NAMES,
|
||||||
ON_MATCH_SUPERVISE,
|
OUTBOUND_ON_MATCH_VALUES,
|
||||||
OUTBOUND_DETECTOR_NAMES,
|
parse_dlp_block,
|
||||||
OUTBOUND_ON_MATCH_VALUES,
|
)
|
||||||
parse_dlp_block,
|
|
||||||
)
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .egress_dlp_config import (
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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", "/app/git_http_backend.py")),
|
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.git_http_backend")),
|
||||||
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.supervise_server")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,12 @@ 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,16 +26,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
# policy_resolver ships flat alongside this file in the gateway
|
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||||
# image (see Dockerfile.gateway); the bot_bottle.* fallback is the
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PORT = 9420
|
DEFAULT_PORT = 9420
|
||||||
@@ -46,12 +38,6 @@ 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"
|
||||||
|
|
||||||
@@ -89,13 +75,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ class ManifestAgentProvider:
|
|||||||
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
||||||
so the Claude Code CLI starts.
|
so the Claude Code CLI starts.
|
||||||
|
|
||||||
`forward_host_credentials` forwards the host Codex auth token into
|
`forward_host_credentials` forwards the host provider auth token into
|
||||||
the egress daemon (Codex only).
|
the egress sidecar (Codex and Claude). For Codex this reads
|
||||||
|
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
@@ -92,10 +93,15 @@ class ManifestAgentProvider:
|
|||||||
f"is only supported for built-in templates "
|
f"is only supported for built-in templates "
|
||||||
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
||||||
)
|
)
|
||||||
if forward_host_credentials and template != "codex":
|
if forward_host_credentials and template not in {"codex", "claude"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
"is currently only supported for template 'codex'"
|
"is only supported for templates 'codex' and 'claude'"
|
||||||
|
)
|
||||||
|
if forward_host_credentials and auth_token:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
|
"and auth_token both set; use one or the other"
|
||||||
)
|
)
|
||||||
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ 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 so it can be COPYed flat into
|
stdlib-only and free of bot-bottle imports.
|
||||||
the gateway.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
+16
-34
@@ -37,40 +37,22 @@ 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,
|
Proposal,
|
||||||
Proposal,
|
Response,
|
||||||
Response,
|
STATUSES,
|
||||||
STATUSES,
|
STATUS_APPROVED,
|
||||||
STATUS_APPROVED,
|
STATUS_MODIFIED,
|
||||||
STATUS_MODIFIED,
|
STATUS_REJECTED,
|
||||||
STATUS_REJECTED,
|
TOOLS,
|
||||||
TOOLS,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
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,9 +26,8 @@ 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).
|
||||||
|
|
||||||
Stdlib-only. The Dockerfile copies this file + bot_bottle/supervise.py
|
The Dockerfile copies this script to /app/supervise_server.py and installs
|
||||||
into the image; the server imports `supervise` for the queue / Proposal
|
the bot_bottle package so its `from bot_bottle.*` imports resolve.
|
||||||
plumbing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -42,29 +41,18 @@ import time
|
|||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
try:
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
# Same-directory imports inside the bundle container; these files are
|
from bot_bottle.egress_addon_core import (
|
||||||
# COPYed flat under /app by Dockerfile.gateway.
|
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||||
from egress_addon_core import (
|
)
|
||||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
)
|
from bot_bottle import supervise as _sv
|
||||||
from policy_resolver import PolicyResolveError, PolicyResolver
|
|
||||||
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"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# PRD prd-new: Claude forward_host_credentials
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-01
|
||||||
|
- **Issue:** #325
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add `agent_provider.forward_host_credentials: true` support for the
|
||||||
|
`claude` template, mirroring the existing Codex flow. When enabled,
|
||||||
|
bot-bottle reads the host's Claude OAuth session key from
|
||||||
|
`~/.claude/.credentials.json` at launch, forwards it only to the egress sidecar,
|
||||||
|
and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so
|
||||||
|
Claude Code starts without ever seeing the real credential.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Running a Claude agent in a container today requires the operator to
|
||||||
|
manually extract a long-lived OAuth token (`claude setup-token`), export
|
||||||
|
it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in
|
||||||
|
the manifest with `agent_provider.auth_token:
|
||||||
|
"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony
|
||||||
|
that is easy to skip or do incorrectly.
|
||||||
|
|
||||||
|
The host already stores a valid Claude session in `~/.claude/.credentials.json`
|
||||||
|
after `claude login`. Codex already automates an
|
||||||
|
equivalent extraction from `~/.codex/auth.json`. There is no reason
|
||||||
|
Claude bottles cannot do the same.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- A Claude bottle with `forward_host_credentials: true` in the manifest
|
||||||
|
uses the host's `~/.claude/.credentials.json` session key at launch with no
|
||||||
|
additional operator steps.
|
||||||
|
- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`
|
||||||
|
— never the real token.
|
||||||
|
- The real session key lives only in the egress sidecar's environment.
|
||||||
|
- Missing, malformed, or expired host Claude auth fails launch with a
|
||||||
|
clear operator-facing message.
|
||||||
|
- Existing `auth_token` behavior is unchanged.
|
||||||
|
- `forward_host_credentials: true` is rejected in the manifest when both
|
||||||
|
`auth_token` and `forward_host_credentials` are set, since they serve
|
||||||
|
the same purpose.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Refreshing Claude OAuth tokens in the sidecar.
|
||||||
|
- Writing a dummy `~/.claude.json` auth state to the agent (unlike the
|
||||||
|
Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
|
in env, not from an auth file — no guest-side auth marker is needed).
|
||||||
|
- Supporting `forward_host_credentials` for providers other than `codex`
|
||||||
|
and `claude`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Manifest schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent_provider:
|
||||||
|
template: claude
|
||||||
|
forward_host_credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Rejects in manifest validation when:
|
||||||
|
- Template is not `codex` or `claude`.
|
||||||
|
- Both `auth_token` and `forward_host_credentials` are set.
|
||||||
|
|
||||||
|
### Host auth extraction (`contrib/claude/claude_auth.py`)
|
||||||
|
|
||||||
|
Claude Code credential storage varies by platform:
|
||||||
|
|
||||||
|
- **Linux**: `~/.claude/.credentials.json`
|
||||||
|
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
|
||||||
|
(the file path is tried first; Keychain is the fallback when the file
|
||||||
|
is absent)
|
||||||
|
|
||||||
|
`~/.claude.json` contains only UI state and profile metadata — no token.
|
||||||
|
|
||||||
|
The credentials JSON schema (same whether from file or Keychain):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "<access-token>",
|
||||||
|
"refreshToken": "<refresh-token>",
|
||||||
|
"expiresAt": 1748276587173,
|
||||||
|
"scopes": ["user:inference", "user:profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expiresAt` is in **milliseconds** (not seconds).
|
||||||
|
|
||||||
|
At prepare/launch time, when `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
|
||||||
|
`security find-generic-password -s "Claude Code-credentials" -w`
|
||||||
|
and parse its stdout as JSON.
|
||||||
|
2. Require a `claudeAiOauth` dict.
|
||||||
|
3. Require a non-empty `claudeAiOauth.accessToken` string.
|
||||||
|
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
|
||||||
|
the result to be in the future.
|
||||||
|
5. Return only the access token to the launch path.
|
||||||
|
|
||||||
|
Errors name the missing or invalid condition and point the operator at
|
||||||
|
`claude login`, without printing token values.
|
||||||
|
|
||||||
|
### Egress route
|
||||||
|
|
||||||
|
When `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
- Provision the session key in `provisioned_env` under
|
||||||
|
`BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`).
|
||||||
|
- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer`
|
||||||
|
and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`.
|
||||||
|
- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and
|
||||||
|
add it to `hidden_env_names`.
|
||||||
|
|
||||||
|
No dummy auth file and no `verify` step are needed — Claude Code reads
|
||||||
|
the credential from the env var, not from a file.
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"`
|
||||||
|
in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`).
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in
|
||||||
|
`agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`).
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host ~/.claude/.credentials.json → bot-bottle launch
|
||||||
|
│
|
||||||
|
├──► egress sidecar env (real token only)
|
||||||
|
│
|
||||||
|
└──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder
|
||||||
|
|
||||||
|
Agent → HTTPS to api.anthropic.com (via egress)
|
||||||
|
Egress → injects Authorization: Bearer <real token>
|
||||||
|
Egress → forwards to api.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None — the Codex precedent makes the design clear.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.backends.legacy:build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "bot-bottle"
|
||||||
|
version = "0.0.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
@@ -9,11 +9,15 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS,
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
build_agent_provision_plan,
|
build_agent_provision_plan,
|
||||||
prompt_args,
|
prompt_args,
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
from bot_bottle.egress import (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _jwt(exp: int) -> str:
|
def _jwt(exp: int) -> str:
|
||||||
@@ -292,6 +296,67 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual({}, plan.provisioned_env)
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
|
def test_claude_forward_host_credentials_populates_egress_route(self):
|
||||||
|
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
home = Path(tmp) / "host-claude"
|
||||||
|
cred_dir = home / ".claude"
|
||||||
|
cred_dir.mkdir(parents=True)
|
||||||
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
|
}))
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=True,
|
||||||
|
host_env={"HOME": str(home)},
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(plan.egress_routes))
|
||||||
|
route = plan.egress_routes[0]
|
||||||
|
self.assertIn(route.host, CLAUDE_HOST_CREDENTIAL_HOSTS)
|
||||||
|
self.assertEqual("Bearer", route.auth_scheme)
|
||||||
|
self.assertEqual(CLAUDE_HOST_CREDENTIAL_TOKEN_REF, route.token_ref)
|
||||||
|
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
|
||||||
|
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||||
|
|
||||||
|
def test_claude_forward_host_credentials_populates_provisioned_env(self):
|
||||||
|
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
home = Path(tmp) / "host-claude"
|
||||||
|
cred_dir = home / ".claude"
|
||||||
|
cred_dir.mkdir(parents=True)
|
||||||
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
|
}))
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=True,
|
||||||
|
host_env={"HOME": str(home)},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
|
||||||
|
plan.provisioned_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claude_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=False,
|
||||||
|
)
|
||||||
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
def test_pi_plan_writes_default_ollama_models(self):
|
def test_pi_plan_writes_default_ollama_models(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = build_agent_provision_plan(
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Unit: host Claude auth extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.contrib.claude.claude_auth import (
|
||||||
|
claude_auth_path,
|
||||||
|
claude_host_access_token,
|
||||||
|
)
|
||||||
|
from bot_bottle.log import Die
|
||||||
|
|
||||||
|
|
||||||
|
def _cred_json(access_token: str, **extra: object) -> str:
|
||||||
|
payload: dict[str, object] = {"claudeAiOauth": {"accessToken": access_token, **extra}}
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeHostAccessToken(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
|
||||||
|
self.home = Path(self.tmp.name)
|
||||||
|
self.cred_dir = self.home / ".claude"
|
||||||
|
self.cred_dir.mkdir()
|
||||||
|
self.auth_path = self.cred_dir / ".credentials.json"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
|
||||||
|
self.auth_path.write_text(json.dumps(payload))
|
||||||
|
|
||||||
|
def test_auth_path_uses_home_env(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth_path,
|
||||||
|
claude_auth_path({"HOME": str(self.home)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- file-based (Linux) ---
|
||||||
|
|
||||||
|
def test_file_returns_access_token(self):
|
||||||
|
key = "sk-ant-oat01-real-key" # gitleaks:allow
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_missing_claude_ai_oauth_dies(self):
|
||||||
|
self._write({"hasCompletedOnboarding": True})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_missing_access_token_dies(self):
|
||||||
|
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_empty_access_token_dies(self):
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": ""}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_expired_token_dies(self):
|
||||||
|
# expiresAt is milliseconds; 1_000_000 ms is year 1970
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, # gitleaks:allow
|
||||||
|
})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token(
|
||||||
|
{"HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_file_future_expiry_is_accepted(self):
|
||||||
|
key = "sk-ant-oat01-y" # gitleaks:allow
|
||||||
|
# 2_000_000_000_000 ms ≈ year 2033
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
|
||||||
|
})
|
||||||
|
out = claude_host_access_token(
|
||||||
|
{"HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_absent_expiry_is_accepted(self):
|
||||||
|
key = "sk-ant-oat01-z" # gitleaks:allow
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_non_json_dies(self):
|
||||||
|
self.auth_path.write_text("not json {{{")
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_json_array_root_dies(self):
|
||||||
|
self.auth_path.write_text("[]")
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_extra_fields_are_ignored(self):
|
||||||
|
key = "sk-ant-oat01-real" # gitleaks:allow
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": key,
|
||||||
|
"refreshToken": "sk-ant-ort01-secret", # gitleaks:allow
|
||||||
|
"scopes": ["user:inference"],
|
||||||
|
"expiresAt": 2_000_000_000_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
# --- macOS Keychain fallback ---
|
||||||
|
|
||||||
|
def _home_without_creds(self) -> Path:
|
||||||
|
"""A home dir that has .claude/ but no .credentials.json."""
|
||||||
|
empty = self.home / "no-creds"
|
||||||
|
(empty / ".claude").mkdir(parents=True)
|
||||||
|
return empty
|
||||||
|
|
||||||
|
def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock:
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.returncode = returncode
|
||||||
|
mock.stdout = stdout
|
||||||
|
return mock
|
||||||
|
|
||||||
|
def test_keychain_used_when_file_absent(self):
|
||||||
|
key = "sk-ant-oat01-keychain" # gitleaks:allow
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain(_cred_json(key)),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
out = claude_host_access_token({"HOME": str(home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_keychain_failure_when_file_absent_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain("", returncode=44),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_no_file_no_keychain_on_linux_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_keychain_non_json_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain("not-json"),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_keychain_security_not_found_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
side_effect=FileNotFoundError,
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -18,7 +18,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gateway-import shims — must run before importing egress_addon
|
# mitmproxy stub — must run before importing egress_addon
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _ensure_shims() -> None:
|
def _ensure_shims() -> None:
|
||||||
@@ -32,9 +32,6 @@ 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,9 +190,6 @@ 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()
|
||||||
|
|||||||
@@ -80,11 +80,19 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
|||||||
"forward_host_credentials": "yes",
|
"forward_host_credentials": "yes",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_forward_host_credentials_rejected_for_claude(self):
|
def test_forward_host_credentials_allowed_for_claude(self):
|
||||||
|
b = _provider_config_bottle({
|
||||||
|
"template": "claude",
|
||||||
|
"forward_host_credentials": True,
|
||||||
|
})
|
||||||
|
self.assertTrue(b.agent_provider.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_host_credentials_and_auth_token_rejected_together(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
_provider_config_bottle({
|
_provider_config_bottle({
|
||||||
"template": "claude",
|
"template": "claude",
|
||||||
"forward_host_credentials": True,
|
"forward_host_credentials": True,
|
||||||
|
"auth_token": "SOME_TOKEN",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_auth_token_defaults_empty(self):
|
def test_auth_token_defaults_empty(self):
|
||||||
|
|||||||
@@ -86,10 +86,22 @@ class TestAgentProviderValidation(unittest.TestCase):
|
|||||||
"b", {"forward_host_credentials": True, "template": "weird"}
|
"b", {"forward_host_credentials": True, "template": "weird"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_forward_creds_non_codex_template(self) -> None:
|
def test_forward_creds_pi_template_rejected(self) -> None:
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgentProvider.from_dict(
|
ManifestAgentProvider.from_dict(
|
||||||
"b", {"forward_host_credentials": True, "template": "claude"}
|
"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"}
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -13,15 +12,9 @@ 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
|
||||||
# The server module loads `supervise` via same-directory import inside
|
from bot_bottle import queue_store as _qs
|
||||||
# the container (Dockerfile.supervise WORKDIRs into /app). For tests
|
from bot_bottle import audit_store as _as
|
||||||
# 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