Compare commits

..

4 Commits

Author SHA1 Message Date
didericis-claude 1553a98275 fix(orchestrator): address review on host control server transport (#468)
prd-number-check / require-numbered-prds (pull_request) Failing after 11s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 59s
test / unit (pull_request) Failing after 52s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 11m18s
Codex review on #496:

- **High — ambiguous delivery no longer orphans a launched bottle.** A
  timeout / dropped response from the host controller is now the ambiguous
  BrokerUnavailableError (distinct from the definite BrokerAuthError /
  BrokerClientError). OrchestratorCore.launch_bottle keeps the registry
  row on the ambiguous case instead of deregistering — deregistering would
  orphan a running container with no record (reconcile reaps rows, never
  containers). The row is left for reconcile to reap iff the bottle is not
  actually live. Definite failures still roll back, so a real failure
  leaves no orphan row.
- **Medium — the privileged endpoint bounds request bodies.** The host
  server rejects an oversized Content-Length with 413 before reading it,
  and sets a per-request socket timeout, so a caller that can merely reach
  the socket (no signed token) can't exhaust memory or a handler thread.

Tests: ambiguous-keep vs definite-rollback in the launch path; the
BrokerUnavailableError/BrokerClientError split in BrokerClient; the 413
body cap + handler error paths (driven in-thread, since daemon request
threads lose coverage) plus a deterministic real-socket check that
declares an oversized Content-Length but sends a sliver (rejection on the
header, no unread-body reset race); and the __main__ entrypoint broker
selection. Diff-coverage 98%; pyright clean; pylint 9.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:13:36 +00:00
didericis-claude 79dd4926bb feat(orchestrator): host control server transport (#468)
prd-number-check / require-numbered-prds (pull_request) Failing after 6s
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
lint / lint (push) Successful in 57s
test / unit (pull_request) Successful in 47s
test / coverage (pull_request) Failing after 16s
Chunk 1 of the host-control-server stack: close the PRD's **transport**
gap. Today LaunchBroker.submit(token) is an in-process method call from
OrchestratorCore; this makes it a real out-of-process service reached
over HTTP.

- host_server.py: the host control server. A pure dispatch() (POST
  /broker verifies a signed token via the existing verify_request +
  _launch/_teardown path, GET /health) wrapped by a thin http.server
  adapter, mirroring orchestrator/server.py. Only the signed token
  crosses the wire; provenance/schema failures are fail-closed 401s that
  never touch the backend, a backend launch failure is a 502.
- broker_client.py: BrokerClient — a drop-in submit(token) that POSTs the
  signed token to the host controller. A 401 re-raises as BrokerAuthError
  so the launch path's rollback is identical local or remote.
- broker.py: SubmitBroker Protocol — the one method OrchestratorCore
  depends on, satisfied by both LaunchBroker and BrokerClient, so the
  core is unchanged (service.py annotation only).
- __main__.py: wire `--broker http` behind the shared-secret env var
  (BOT_BOTTLE_BROKER_SECRET, hex) — a chunk-1 stopgap the durable
  TrustDomain key (chunk 2, #476) replaces.

Tested: pure-dispatch cases, BrokerClient with HTTP mocked, and a
real-socket sign -> POST -> verify -> act round-trip (incl. fail-closed
forged token). pyright clean; pylint 9.86.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 07:16:56 +00:00
didericis-claude a86ad630e2 docs: defer broker replay protection to its own issue (#494)
prd-number-check / require-numbered-prds (pull_request) Failing after 9s
lint / lint (push) Successful in 1m0s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
Per PR review, replay protection is too heavy for the host control
server MVP. Drop it from the four-gap framing (now three gaps), remove
the enforcement design section and implementation chunk, and track the
iat-window + jti-cache work in #494 as an independent in-process change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:56:34 -04:00
didericis-claude 8e459647fb docs: PRD for host-side control server (#468)
Promote the in-process launch broker into a standalone host control
server: the single privileged host component that brokers launches, owns
orchestrator lifecycle, and is the sole writer of host-durable state.

Closes the four broker gaps (transport, durable provisioned secret,
replay protection, disciplined op vocabulary) and splits host state by
owner and lifetime (orchestrator SQLite / host JSONL audit / gateway
none). The payoff is dropping the Docker socket from the CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:56:34 -04:00
103 changed files with 1452 additions and 10663 deletions
+3 -6
View File
@@ -2,7 +2,7 @@
# digest, etc.) without coupling every dev push to upstream registry
# availability.
#
# Opt-in via BOT_BOTTLE_RUN_CANARIES=1 so the same files can be run
# Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run
# locally with the same gating.
name: canaries
@@ -17,7 +17,7 @@ jobs:
canaries:
runs-on: ubuntu-latest
env:
BOT_BOTTLE_RUN_CANARIES: "1"
CLAUDE_BOTTLE_RUN_CANARIES: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -25,7 +25,4 @@ jobs:
# No actions/setup-python: canaries are stdlib unittest on the image's
# system Python 3.12 (older act_runner mishandles setup-python's PATH).
- name: Run canaries
run: |
python3 -m scripts.unittest_gate \
-t . -s tests/canaries -v \
--minimum-executed 1 --fail-on-skip
run: python3 -m unittest discover -t . -s tests/canaries -v
-10
View File
@@ -4,13 +4,6 @@ on:
push:
paths:
- "**.py"
- "Dockerfile*"
- "bot_bottle/contrib/*/Dockerfile"
- "bot_bottle/contrib/*/package.json"
- "bot_bottle/contrib/*/package-lock.json"
- "bot_bottle/contrib/codex/codex-package_SHA256SUMS"
- "requirements.gateway.*"
- "image-build-args.json"
- ".pylintrc"
- ".gitea/workflows/lint.yml"
@@ -20,9 +13,6 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Enforce immutable image inputs
run: python3 scripts/check_image_inputs.py
# No actions/setup-python: the runner image already ships Python 3.12,
# and older act_runner engines mishandle setup-python's PATH. Install
# into the ephemeral job container's system Python — the pylint/pyright
-1
View File
@@ -3,7 +3,6 @@ name: prd-number-check
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [main]
jobs:
require-numbered-prds:
+1 -27
View File
@@ -60,9 +60,6 @@ jobs:
integration-docker:
runs-on: ubuntu-latest
concurrency:
group: integration-docker-infra
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -87,30 +84,7 @@ jobs:
env:
BOT_BOTTLE_BACKEND: docker
COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
run: |
set -euo pipefail
DOCKER_CLIENT_NETWORK=$(
docker inspect "$(hostname)" |
python3 -c 'import json,sys; n=json.load(sys.stdin)[0]["NetworkSettings"]["Networks"]; print(next(iter(n)))'
)
test -n "$DOCKER_CLIENT_NETWORK"
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
export NO_PROXY="*"
export no_proxy="*"
export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK"
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
python3 -m coverage run -m scripts.unittest_gate \
-t . -s tests/integration -v \
--minimum-executed 22 --fail-on-skip
- name: Clean Docker integration volumes
if: always()
run: |
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
docker volume rm --force \
"bot-bottle-ci-root-$RUN_KEY" \
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
- name: Stage docker coverage for upload
-97
View File
@@ -1,97 +0,0 @@
# Manually refresh the committed package locks and the pinned Codex installer
# checksum after deliberately changing a direct version in the source files.
#
# The job uploads the generated files for review; it never commits or pushes.
name: refresh-image-locks
on:
workflow_dispatch:
push:
paths:
- 'requirements.gateway.in'
- 'bot_bottle/contrib/claude/package.json'
- 'bot_bottle/contrib/pi/package.json'
- 'bot_bottle/contrib/codex/Dockerfile'
- 'image-build-args.json'
- '.gitea/workflows/refresh-image-locks.yml'
permissions:
code: read
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve image runtime versions
id: runtimes
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
import re
inputs = json.load(open("image-build-args.json"))
for output, name in (
("python-version", "PYTHON_BASE_IMAGE"),
("node-version", "NODE_BASE_IMAGE"),
):
match = re.search(r":(\d+\.\d+\.\d+)-", inputs[name])
if match is None:
raise SystemExit(f"cannot resolve runtime version from {name}")
print(f"{output}={match.group(1)}")
PY
- name: Use the image Python version
uses: actions/setup-python@v5
with:
python-version: '${{ steps.runtimes.outputs.python-version }}'
- name: Use the image Node version
uses: actions/setup-node@v4
with:
node-version: '${{ steps.runtimes.outputs.node-version }}'
- name: Compile gateway Python lock
run: |
python3 -m venv /tmp/image-lock-tools
/tmp/image-lock-tools/bin/python -m pip install \
pip==25.2 \
pip-tools==7.5.1
/tmp/image-lock-tools/bin/python -m piptools compile \
--generate-hashes \
--output-file requirements.gateway.lock \
requirements.gateway.in
- name: Resolve provider npm locks
run: |
for provider in claude pi; do
(
cd "bot_bottle/contrib/$provider"
npm install --package-lock-only --ignore-scripts --no-audit --no-fund
)
done
python3 scripts/complete_npm_lock_integrity.py \
bot_bottle/contrib/claude/package-lock.json \
bot_bottle/contrib/pi/package-lock.json
- name: Refresh pinned Codex archive checksums
run: |
CODEX_VERSION=$(
sed -n 's/^ARG CODEX_VERSION=//p' bot_bottle/contrib/codex/Dockerfile
)
test -n "$CODEX_VERSION"
curl -fsSL \
"https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-package_SHA256SUMS" \
-o bot_bottle/contrib/codex/codex-package_SHA256SUMS
- name: Upload refreshed inputs
uses: actions/upload-artifact@v3
with:
name: image-input-locks
path: |
requirements.gateway.lock
bot_bottle/contrib/claude/package-lock.json
bot_bottle/contrib/pi/package-lock.json
bot_bottle/contrib/codex/codex-package_SHA256SUMS
+5 -158
View File
@@ -12,49 +12,33 @@ on:
- 'bot_bottle/**'
- 'tests/**/*.py'
- 'cli.py'
- 'install.sh'
- 'setup.py'
- 'MANIFEST.in'
- 'flake.nix'
- 'nix/firecracker-netpool.nix'
- 'scripts/coverage.sh'
- 'scripts/critical-modules.txt'
- 'scripts/**/*.py'
- 'scripts/diff_coverage.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*'
- 'image-build-args.json'
- 'pyproject.toml'
- 'requirements-dev.txt'
- 'requirements.gateway.*'
- '.coveragerc'
- '.dockerignore'
- '.gitea/workflows/test.yml'
- '.gitea/workflows/refresh-image-locks.yml'
- '.gitea/workflows/pre-release-test.yml'
pull_request:
paths:
- 'bot_bottle/**'
- 'tests/**/*.py'
- 'cli.py'
- 'install.sh'
- 'setup.py'
- 'MANIFEST.in'
- 'flake.nix'
- 'nix/firecracker-netpool.nix'
- 'scripts/coverage.sh'
- 'scripts/critical-modules.txt'
- 'scripts/**/*.py'
- 'scripts/diff_coverage.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*'
- 'image-build-args.json'
- 'pyproject.toml'
- 'requirements-dev.txt'
- 'requirements.gateway.*'
- '.coveragerc'
- '.dockerignore'
- '.gitea/workflows/test.yml'
- '.gitea/workflows/refresh-image-locks.yml'
- '.gitea/workflows/pre-release-test.yml'
jobs:
unit:
@@ -87,9 +71,6 @@ jobs:
integration-docker:
runs-on: ubuntu-latest
concurrency:
group: integration-docker-infra
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -106,33 +87,7 @@ jobs:
env:
BOT_BOTTLE_BACKEND: docker
COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
run: |
set -euo pipefail
# act_runner executes this job in a container while sharing the host
# Docker socket. Attach control-plane siblings to the job's network,
# and use named volumes for state the host daemon must mount.
DOCKER_CLIENT_NETWORK=$(
docker inspect "$(hostname)" |
python3 -c 'import json,sys; n=json.load(sys.stdin)[0]["NetworkSettings"]["Networks"]; print(next(iter(n)))'
)
test -n "$DOCKER_CLIENT_NETWORK"
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
export NO_PROXY="*"
export no_proxy="*"
export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK"
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
python3 -m coverage run -m scripts.unittest_gate \
-t . -s tests/integration -v \
--minimum-executed 22 --fail-on-skip
- name: Clean Docker integration volumes
if: always()
run: |
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
docker volume rm --force \
"bot-bottle-ci-root-$RUN_KEY" \
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
- name: Stage docker coverage for upload
run: cp .coverage.docker coverage-docker.dat
@@ -143,114 +98,6 @@ jobs:
name: coverage-docker
path: coverage-docker.dat
image-input-builds:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Verify shared bases cover supported architectures
run: |
set -euo pipefail
python_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
docker_cli_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["DOCKER_CLI_BASE_IMAGE"])')
test -n "$python_ref"
test -n "$node_ref"
test -n "$docker_cli_ref"
for ref in "$python_ref" "$node_ref" "$docker_cli_ref"; do
docker buildx imagetools inspect --raw "$ref" |
python3 -c '
import json
import sys
manifest = json.load(sys.stdin)
platforms = {
(item["platform"]["os"], item["platform"]["architecture"])
for item in manifest["manifests"]
if item.get("platform", {}).get("os") != "unknown"
}
required = {("linux", "amd64"), ("linux", "arm64")}
missing = required - platforms
if missing:
raise SystemExit(f"base manifest lacks supported platforms: {missing}")
'
done
- name: Verify Codex archives for all supported architectures
run: |
set -euo pipefail
codex_version=$(
sed -n 's/^ARG CODEX_VERSION=//p' bot_bottle/contrib/codex/Dockerfile
)
test -n "$codex_version"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for target in \
aarch64-unknown-linux-musl \
x86_64-unknown-linux-musl
do
asset="codex-package-${target}.tar.gz"
expected=$(
awk -v asset="$asset" '$2 == asset { print $1 }' \
bot_bottle/contrib/codex/codex-package_SHA256SUMS
)
test -n "$expected"
curl -fsSL \
"https://github.com/openai/codex/releases/download/rust-v${codex_version}/${asset}" \
-o "$tmp/$asset"
echo "$expected $tmp/$asset" | sha256sum -c -
tar -tzf "$tmp/$asset" > "$tmp/$asset.contents"
grep -Fx 'bin/codex' "$tmp/$asset.contents"
grep -Fx 'bin/codex-code-mode-host' "$tmp/$asset.contents"
grep -Fx 'codex-package.json' "$tmp/$asset.contents"
done
- name: Build and smoke-test all supported images
run: |
set -euo pipefail
suffix="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-image-inputs}}"
orchestrator="bot-bottle-orchestrator-inputs:${suffix}"
gateway="bot-bottle-gateway-inputs:${suffix}"
orchestrator_fc="bot-bottle-orchestrator-fc-inputs:${suffix}"
claude="bot-bottle-claude-inputs:${suffix}"
codex="bot-bottle-codex-inputs:${suffix}"
pi="bot-bottle-pi-inputs:${suffix}"
python_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
-t "$orchestrator" -f Dockerfile.orchestrator .
orchestrator_id=$(docker image inspect --format '{{.Id}}' "$orchestrator")
case "$orchestrator_id" in sha256:*) ;; *) exit 1 ;; esac
orchestrator_base="bot-bottle-orchestrator-inputs:sha256-${orchestrator_id#sha256:}"
docker image tag "$orchestrator_id" "$orchestrator_base"
test "$(
docker image inspect --format '{{.Id}}' "$orchestrator_base"
)" = "$orchestrator_id"
docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
-t "$gateway" -f Dockerfile.gateway .
docker build \
--build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_base" \
-t "$orchestrator_fc" -f Dockerfile.orchestrator.fc .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$claude" -f bot_bottle/contrib/claude/Dockerfile .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$codex" -f bot_bottle/contrib/codex/Dockerfile .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$pi" -f bot_bottle/contrib/pi/Dockerfile .
docker run --rm --entrypoint python3 "$orchestrator" -c \
'import bot_bottle.orchestrator'
docker run --rm --entrypoint mitmdump "$gateway" --version
docker run --rm "$claude" claude --version
docker run --rm "$codex" codex --version
docker run --rm "$pi" pi --version
coverage:
needs: [unit, integration-docker]
timeout-minutes: 15
+6 -15
View File
@@ -33,28 +33,19 @@ jobs:
- name: Run coverage and extract percentage
id: coverage
run: |
set -euo pipefail
# Never publish a badge from a failed or partial test run.
python3 -m coverage run -m unittest discover -t . -s tests/unit
REPORT=$(python3 -m coverage report)
printf '%s\n' "$REPORT"
PERCENT=$(printf '%s\n' "$REPORT" | awk '$1 == "TOTAL" {gsub("%", "", $NF); print $NF}')
test -n "$PERCENT"
python3 -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)
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Coverage: $PERCENT%"
- name: Extract core (critical-module) coverage percentage
id: core_coverage
run: |
set -euo pipefail
# Reuses the .coverage data from the previous step. The core list is
# validated single source of truth. Fail if a listed path disappeared
# or if the measured core falls below ADR 0004's 90% minimum.
INCLUDE=$(python3 scripts/critical_modules.py)
REPORT=$(python3 -m coverage report --include="$INCLUDE" --fail-under=90)
printf '%s\n' "$REPORT"
PERCENT=$(printf '%s\n' "$REPORT" | awk '$1 == "TOTAL" {gsub("%", "", $NF); print $NF}')
test -n "$PERCENT"
# 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.
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)
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Core coverage: $PERCENT%"
+6 -21
View File
@@ -36,23 +36,11 @@
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
# 9100 supervise (MCP HTTP)
# Based on an exact `python:3.12.13-slim-trixie` multi-architecture manifest
# rather than the
# Based on `python:3.12-slim` (Debian trixie) rather than the
# `mitmproxy/mitmproxy` image (Debian bookworm), matching the trixie base the
# orchestrator image needs for buildah (Dockerfile.orchestrator.fc). mitmproxy
# is pip-installed to the same effect as the upstream image.
ARG PYTHON_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE}
# Freeze apt's package universe as well as the base filesystem. Without a
# snapshot, the same Dockerfile resolves different package versions over time.
ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
/etc/apt/sources.list.d/debian.sources
FROM python:3.12-slim
# Runtime system deps:
# git supplies the `git daemon` subcommand (no separate package)
@@ -60,19 +48,16 @@ RUN sed -i \
# openssh-client supplies the upstream SSH transport the
# pre-receive hook uses to forward accepted refs.
# ca-certificates is needed for mitmdump upstream TLS.
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# mitmdump (the egress data plane). The upstream mitmproxy image baked
# this in; on the plain python base we install a fully resolved lock whose
# distributions are all hash-verified. Its CA dir is set explicitly via
# `--set confdir=` in
# this in; on the plain python base we pip-install the same pinned
# version. Its CA dir is set explicitly via `--set confdir=` in
# egress-entrypoint.sh, so it doesn't depend on a `mitmproxy` home user.
COPY requirements.gateway.lock /tmp/requirements.gateway.lock
RUN pip install --no-cache-dir --require-hashes \
-r /tmp/requirements.gateway.lock
RUN pip install --no-cache-dir mitmproxy==11.1.3
# gitleaks (the pre-receive hook's secret scanner). Installed from its
# official release, pinned by version + SHA256 and verified — rather than
+2 -5
View File
@@ -16,12 +16,9 @@
# secret-dense control plane on a minimal dependency surface is the point
# (PRD 0070's "secret concentration").
#
# Shares an exact multi-architecture Python/trixie manifest with the gateway
# image. The version-qualified tag keeps the human-readable upstream version;
# the digest makes the bytes immutable.
# Shares the trixie `python:3.12-slim` base with the gateway image.
ARG PYTHON_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE}
FROM python:3.12-slim
WORKDIR /app
+3 -14
View File
@@ -12,21 +12,10 @@
# bare microVM (no fuse-overlayfs / overlay module / subuid maps). The trixie
# base (from Dockerfile.orchestrator's python:3.12-slim) carries buildah 1.39,
# which parses the Dockerfile heredocs agent images use (bookworm's 1.28 can't).
# Matches image_builder. There is deliberately no default: the build coordinator
# passes the exact local image ID returned by `docker image inspect`, so this
# stage cannot silently resolve a stale `:latest` tag.
ARG ORCHESTRATOR_BASE_IMAGE
FROM ${ORCHESTRATOR_BASE_IMAGE}
# Matches image_builder.
FROM bot-bottle-orchestrator:latest
ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
/etc/apt/sources.list.d/debian.sources
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
buildah crun netavark aardvark-dns \
&& rm -rf /var/lib/apt/lists/*
-3
View File
@@ -4,8 +4,5 @@
include Dockerfile.gateway
include Dockerfile.orchestrator
include Dockerfile.orchestrator.fc
include image-build-args.json
include requirements.gateway.in
include requirements.gateway.lock
include nix/firecracker-netpool.nix
include scripts/firecracker-netpool.sh
+3 -3
View File
@@ -5,7 +5,7 @@
# bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-84%25-brightgreen)](https://coverage.readthedocs.io/)
[![coverage](https://img.shields.io/badge/coverage-83%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-94%25-brightgreen)](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.
@@ -75,7 +75,7 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
> **CI (macOS Apple Container):** the advisory `integration-macos` job in `.gitea/workflows/pre-release-test.yml` runs only on manual dispatch. It targets a self-hosted host-mode runner labelled `macos`; Apple Container cannot run inside the Linux pull-request runner. Provision an Apple Silicon host with the `container` CLI running and Python ≥ 3.11 plus `coverage` on the launchd service's explicit `PATH`. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1. Its coverage is reported separately and never feeds the required pull-request gate.
> **CI (macOS Apple Container):** the `integration-macos` job (`.gitea/workflows/test.yml`) runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container` on a self-hosted macOS runner labelled `macos`, because Apple Container needs the host virtualization framework and cannot run in a Linux container (so it can't reuse the `kvm` runner). Provision an Apple Silicon host with the `container` CLI on `PATH` and `container system status` running, then register the runner in **host mode** (not docker mode) with the `macos` label — `brew install gitea-runner` (the `act_runner` rename). Give it a Python ≥ 3.11 with `coverage` importable on the launchd service's `PATH` (a launchd service doesn't inherit your shell profile, so pin `node` and the Python env explicitly). The job is **advisory** — `workflow_dispatch` (manual) only, never triggered by push or PR — since a single laptop that sleeps/roams must not block merges or churn on every push to main; its coverage doesn't feed the gate. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1.
### Containers inside a bottle
@@ -174,7 +174,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
> **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`.
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. A Docker-capable hosted job builds the candidate once; KVM tests boot those exact bytes, and a successful main run publishes them. The unit/lint jobs still run on `ubuntu-latest`.
```sh
./cli.py start <agent> # builds the image on first run, drops you into claude
+75 -11
View File
@@ -23,14 +23,14 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
from ..util import expand_tilde
from ..manifest import Manifest, ManifestIndex
from ..supervisor.plan import SupervisePlan
from ..env import ResolvedEnv
from ..env import resolve_env, ResolvedEnv
from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
@@ -296,18 +296,82 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created."""
from .preparation import BottlePreparationPlanner
prepared = BottlePreparationPlanner(self).prepare(spec)
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
reject_nested_containers,
resolve_manifest_dockerfile,
write_launch_metadata,
)
manifest = self._validate(spec)
if not self.supports_nested_containers:
reject_nested_containers(self.name, manifest)
self._preflight()
from ..git_gate import GitGate
manifest = GitGate().preflight_host_keys(
manifest,
headless=spec.headless,
home_md=spec.manifest.home_md,
)
manifest_bottle = manifest.bottle
manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template)
resolved_env = resolve_env(manifest)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
# Manifest may override the Dockerfile per-bottle; otherwise fall
# back to the provider plugin's bundled Dockerfile (next to its
# agent_provider.py module).
if manifest_agent_provider.dockerfile:
agent_dockerfile_path = resolve_manifest_dockerfile(
manifest_agent_provider.dockerfile, spec,
)
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
instance_name=f"bot-bottle-{slug}",
prompt_file=prompt_file,
guest_env=self._build_guest_env(resolved_env),
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
auth_token=manifest_agent_provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace.workdir,
label=spec.label,
color=spec.color,
provider_settings=manifest_agent_provider.settings,
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
spec,
manifest=prepared.manifest,
slug=prepared.slug,
resolved_env=prepared.resolved_env,
agent_provision_plan=prepared.agent_provision_plan,
egress_plan=prepared.egress_plan,
supervise_plan=prepared.supervise_plan,
git_gate_plan=prepared.git_gate_plan,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
+7 -58
View File
@@ -17,10 +17,6 @@ from ...gateway import (
DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
)
DEFAULT_GATEWAY_SUBNET = "10.242.255.0/24"
_GATEWAY_SUBNET_LABEL = "bot-bottle.gateway-subnet"
class DockerGateway(Gateway):
"""The consolidated gateway as a single, fixed-name Docker container.
@@ -39,8 +35,6 @@ class DockerGateway(Gateway):
build_context: Path | None = None,
dockerfile: str | None = GATEWAY_DOCKERFILE,
host_port_bindings: tuple[int, ...] = (),
ca_mount_source: str | Path | None = None,
subnet: str | None = None,
) -> None:
self.image_ref = image_ref
self.name = name
@@ -65,15 +59,6 @@ class DockerGateway(Gateway):
# backend's dev-harness gateway so VMs can reach it via their TAP link;
# Docker's DNAT + the nft `ct status dnat accept` rule handle the rest.
self._host_port_bindings = host_port_bindings
self._subnet = (
subnet
or os.environ.get("BOT_BOTTLE_DOCKER_GATEWAY_SUBNET", "").strip()
or DEFAULT_GATEWAY_SUBNET
)
configured_ca = os.environ.get("BOT_BOTTLE_DOCKER_CA_MOUNT", "").strip()
self._ca_mount_source = str(
ca_mount_source or configured_ca or host_gateway_ca_dir()
)
def image_exists(self) -> bool:
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
@@ -96,11 +81,6 @@ class DockerGateway(Gateway):
str(context)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache")
for name, value in resources.image_build_args(
self._dockerfile,
context=context,
).items():
argv[-1:-1] = ["--build-arg", f"{name}={value}"]
proc = run_docker(argv)
if proc.returncode != 0:
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
@@ -129,41 +109,10 @@ class DockerGateway(Gateway):
def _ensure_network(self) -> None:
"""Create the shared gateway network if it doesn't exist. Idempotent —
a concurrent create loses harmlessly (the loser sees 'already exists').
The explicit subnet is required because bottle attribution pins source
IPs; Docker rejects static endpoint addresses on an auto-IPAM network."""
inspected = run_docker([
"docker", "network", "inspect",
"--format", f'{{{{index .Labels "{_GATEWAY_SUBNET_LABEL}"}}}}',
self.network,
])
if inspected.returncode == 0:
marker = inspected.stdout.strip()
if marker in {"", self._subnet}:
return
if inspected.returncode == 0:
# Migrate the stale auto-IPAM network created by older releases.
# Removing the fixed gateway is safe here: this launch recreates it.
run_docker(["docker", "rm", "--force", self.name])
removed = run_docker(["docker", "network", "rm", self.network])
if removed.returncode != 0:
raise GatewayError(
f"gateway network {self.network} needs explicit subnet "
f"{self._subnet} but could not be replaced: "
f"{removed.stderr.strip()}"
)
proc = run_docker([
"docker", "network", "create",
# bot-bottle attribution pins IPv4 source IPs; it has no IPv6
# support. Disable IPv6 explicitly so a daemon that default-enables
# it (default-address-pools) can't attach an fdd0::/64 subnet — a
# malformed `::1/64` gateway address then trips docker's own
# ParseAddr in `network inspect`/`ls`, which poisons every launch
# that reads this network's subnet.
"--ipv6=false",
"--subnet", self._subnet,
"--label", f"{_GATEWAY_SUBNET_LABEL}={self._subnet}",
self.network,
])
Docker picks the subnet; the launcher reads it back to allocate IPs."""
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
return
proc = run_docker(["docker", "network", "create", self.network])
if proc.returncode != 0 and "already exists" not in proc.stderr:
raise GatewayError(
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
@@ -194,9 +143,9 @@ class DockerGateway(Gateway):
# Recreate when the running container's image is stale (a rebuild),
# so source changes to the gateway's flat daemons take effect — not
# just when the container is absent.
self._ensure_network()
if self.is_running() and self._running_image_is_current():
return
self._ensure_network()
# Clear any stale (stopped OR outdated-image) container holding the
# fixed name, then start fresh. `rm --force` on an absent name is a
# tolerated no-op.
@@ -209,7 +158,7 @@ class DockerGateway(Gateway):
# Persist the self-generated CA on the host so it survives both
# container recreation AND docker volume pruning (agents trust it)
# — see host_gateway_ca_dir / issue #450.
"--volume", f"{self._ca_mount_source}:{MITMPROXY_HOME}",
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# No DB mount: the data plane (egress / supervise / git-gate) reaches
# the supervise queue over the control-plane RPC and never opens
# bot-bottle.db, so the gateway container gets no file handle on it
@@ -304,4 +253,4 @@ class DockerGateway(Gateway):
def provisioning_transport(self) -> GatewayTransport:
"""The exec/cp transport git-gate provisioning stages per-bottle repos +
deploy keys through (over the docker socket)."""
return DockerGatewayTransport(self.name)
return DockerGatewayTransport(self.name)
+4 -11
View File
@@ -33,6 +33,7 @@ from .orchestrator import (
ORCHESTRATOR_NAME,
ORCHESTRATOR_NETWORK,
)
from ...paths import bot_bottle_root
from ... import resources
from ...gateway import (
GATEWAY_IMAGE,
@@ -68,8 +69,6 @@ class DockerInfraService(InfraService):
gateway_image: str = GATEWAY_IMAGE,
repo_root: Path | None = None,
host_root: Path | None = None,
root_mount_source: str | Path | None = None,
gateway_ca_mount_source: str | Path | None = None,
orchestrator_name: str = ORCHESTRATOR_NAME,
orchestrator_label: str = ORCHESTRATOR_LABEL,
gateway_name: str = GATEWAY_NAME,
@@ -79,14 +78,10 @@ class DockerInfraService(InfraService):
self.control_network = control_network
self.orchestrator_image = orchestrator_image
self.gateway_image = gateway_image
# Build context: the repo root in a checkout, a staged copy from the
# installed wheel otherwise (bot_bottle.resources).
# Build context / bind-mount source: the repo root in a checkout, a
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
if host_root is not None and root_mount_source is not None:
raise ValueError("pass host_root or root_mount_source, not both")
self._host_root = host_root
self._root_mount_source = root_mount_source
self._gateway_ca_mount_source = gateway_ca_mount_source
self._host_root = host_root or bot_bottle_root()
self._orchestrator_name = orchestrator_name
self._orchestrator_label = orchestrator_label
self._gateway_name = gateway_name
@@ -103,7 +98,6 @@ class DockerInfraService(InfraService):
control_network=self.control_network,
repo_root=self._repo_root,
host_root=self._host_root,
root_mount_source=self._root_mount_source,
)
def gateway(self) -> DockerGateway:
@@ -118,7 +112,6 @@ class DockerInfraService(InfraService):
network=self.network,
control_network=self.control_network,
build_context=self._repo_root,
ca_mount_source=self._gateway_ca_mount_source,
)
def ensure_running(
+19 -60
View File
@@ -42,9 +42,13 @@ ORCHESTRATOR_IMAGE = os.environ.get(
)
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# Baked as a container label so `ensure_running` can detect whether the running
# orchestrator image was built from the current source.
# orchestrator is executing the current bind-mounted source.
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"
# The bind-mount path for the live control-plane source inside the container.
# PYTHONPATH points here so a code change takes effect on the next launch
# without an image rebuild.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The
# control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT ->
# host_db_path()); it is the ONLY container with a handle on it (issue #469).
@@ -68,51 +72,23 @@ class DockerOrchestrator(Orchestrator):
control_network: str = ORCHESTRATOR_NETWORK,
repo_root: Path | None = None,
host_root: Path | None = None,
root_mount_source: str | Path | None = None,
client_host: str | None = None,
client_network: str | None = None,
bind_host: str | None = None,
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
) -> None:
if host_root is not None and root_mount_source is not None:
raise ValueError("pass host_root or root_mount_source, not both")
self.image_ref = image_ref
self.name = name
self.label = label
self.port = port
self.control_network = control_network
# Build context: the repo root in a checkout, a staged copy from the
# installed wheel otherwise (bot_bottle.resources).
# Build context / bind-mount source: the repo root in a checkout, a
# staged copy from the installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root()
configured_root = os.environ.get("BOT_BOTTLE_DOCKER_ROOT_MOUNT", "").strip()
self._root_mount_source = str(
root_mount_source or configured_root or host_root or bot_bottle_root()
)
configured_network = os.environ.get(
"BOT_BOTTLE_DOCKER_CLIENT_NETWORK", ""
).strip()
self._client_network = client_network or configured_network or None
configured_host = os.environ.get(
"BOT_BOTTLE_DOCKER_HOST_ADDRESS", ""
).strip()
self._client_host = (
client_host or configured_host
or (self.name if self._client_network else "127.0.0.1")
)
# A socket-shared CI runner reaches published ports through its Docker
# network rather than its own loopback. Production stays bound to host
# loopback unless a caller explicitly selects another client.
self._bind_host = bind_host or (
"0.0.0.0"
if not self._client_network and self._client_host != "127.0.0.1"
else "127.0.0.1"
)
self._host_root = host_root or bot_bottle_root()
self._dockerfile = dockerfile
def url(self) -> str:
"""Control-plane URL reachable by this Docker client."""
port = DEFAULT_PORT if self._client_network else self.port
return f"http://{self._client_host}:{port}"
"""Host-side control-plane URL — the orchestrator's published loopback,
which the CLI reaches."""
return f"http://127.0.0.1:{self.port}"
def gateway_url(self) -> str:
"""The URL the gateway's data plane resolves policy against — the
@@ -131,11 +107,6 @@ class DockerOrchestrator(Orchestrator):
str(self._repo_root)]
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
argv.insert(2, "--no-cache")
for name, value in resources.image_build_args(
self._dockerfile,
context=self._repo_root,
).items():
argv[-1:-1] = ["--build-arg", f"{name}={value}"]
proc = run_docker(argv)
if proc.returncode != 0:
raise GatewayError(
@@ -148,7 +119,8 @@ class DockerOrchestrator(Orchestrator):
return self.name in proc.stdout.split()
def _source_current(self, current_hash: str) -> bool:
"""True iff the running orchestrator image matches current source."""
"""True iff the running orchestrator was started from the current
bind-mounted source."""
if not self.is_running():
return False
proc = run_docker([
@@ -210,19 +182,15 @@ class DockerOrchestrator(Orchestrator):
# Control network only — agents are never on it, so they have no
# route to the control plane (the L3 block, not just the JWT).
"--network", self.control_network,
# Host CLI reaches the control plane here (loopback by default).
# Socket-shared CI joins the container directly to the job network;
# the host-side mapping remains loopback-only in that topology. The
# Host CLI reaches the control plane here (loopback only). The
# orchestrator listens on the fixed DEFAULT_PORT inside the
# container; self.port is the host-side published port.
"--publish", f"{self._bind_host}:{self.port}:{DEFAULT_PORT}",
# The image was rebuilt from `_repo_root` immediately before this
# launch. Running its baked package avoids a host-path bind mount,
# which is both more production-like and works with socket-shared
# CI where the daemon cannot see the job container's workspace.
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
# Live control-plane source (code changes without an image rebuild).
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
# Orchestrator registry DB on the host (sole writer: control plane).
# `root_mount_source` may be a host path or a named Docker volume.
"--volume", f"{self._root_mount_source}:{_ROOT_IN_CONTAINER}",
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
# The signing key — held ONLY by the orchestrator (it verifies
# tokens); the gateway gets the pre-minted `gateway` JWT, never the
@@ -237,15 +205,6 @@ class DockerOrchestrator(Orchestrator):
raise OrchestratorStartError(
f"orchestrator container failed to start: {proc.stderr.strip()}"
)
if self._client_network:
proc = run_docker([
"docker", "network", "connect", self._client_network, self.name,
])
if proc.returncode != 0:
raise OrchestratorStartError(
f"orchestrator container failed to join client network "
f"{self._client_network}: {proc.stderr.strip()}"
)
def stop(self) -> None:
"""Remove the control-plane container (idempotent)."""
+15 -65
View File
@@ -6,18 +6,12 @@ from __future__ import annotations
import os
from datetime import datetime, timezone
import re
import shutil
import subprocess
from typing import Iterator
from ... import resources
from ...log import die, info
from ...util import slugify as _slugify
def slugify(name: str) -> str:
"""Compatibility wrapper; new generic callers import ``bot_bottle.util``."""
return _slugify(name)
def run_docker(
@@ -120,13 +114,20 @@ def docker_cp(src: str, dest: str) -> None:
f"{(result.stderr or '').strip() or '<no stderr>'}")
def build_image(
ref: str,
context: str,
*,
dockerfile: str = "",
build_args: dict[str, str] | None = None,
) -> None:
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def slugify(name: str) -> str:
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
if not name:
die("slugify: missing name")
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
if not slug:
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
return slug
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
"""Invokes `docker build` every call. Layer cache makes no-change
rebuilds cheap; running every time means Dockerfile edits land
without manual `docker rmi`.
@@ -146,61 +147,10 @@ def build_image(
args.append("--no-cache")
if dockerfile:
args.extend(["-f", dockerfile])
effective_build_args = resources.image_build_args(
dockerfile,
context=context,
) if dockerfile else {}
effective_build_args.update(build_args or {})
for name, value in effective_build_args.items():
args.extend(["--build-arg", f"{name}={value}"])
args.append(context)
subprocess.run(args, check=True)
def image_id(ref: str) -> str:
"""Return the exact content-addressed ID for a local image.
This is used when one locally built image is another Dockerfile's base:
passing the ID prevents a mutable tag from being resolved between builds.
"""
result = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", ref])
image = result.stdout.strip()
if result.returncode != 0 or not image.startswith("sha256:"):
detail = (result.stderr or result.stdout or "").strip()
die(f"could not resolve exact image ID for {ref!r}: {detail or '<no detail>'}")
return image
def pinned_local_image_ref(ref: str) -> str:
"""Give a local image a content-derived tag and verify the tag resolves
back to the same image ID.
BuildKit treats a bare ``sha256:...`` ID in ``FROM`` as a registry
repository name. A tag whose complete suffix is the local image ID remains
resolvable by BuildKit, while the post-tag inspection keeps the handoff
fail-closed.
"""
image = image_id(ref)
digest = image.removeprefix("sha256:")
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
die(f"could not derive a local base tag from invalid image ID {image!r}")
repository = ref.split("@", 1)[0]
last_slash = repository.rfind("/")
last_colon = repository.rfind(":")
if last_colon > last_slash:
repository = repository[:last_colon]
pinned_ref = f"{repository}:sha256-{digest}"
result = run_docker(["docker", "image", "tag", image, pinned_ref])
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
die(f"could not tag exact local image {image}: {detail or '<no detail>'}")
if image_id(pinned_ref) != image:
die(f"content-derived local tag {pinned_ref!r} did not resolve to {image}")
return pinned_ref
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image
+1 -2
View File
@@ -11,8 +11,7 @@ from pathlib import Path
from ..bottle_state import egress_state_dir
from ..egress import EGRESS_ROUTES_FILENAME
from ..gateway.egress.schema import load_config
from ..gateway.egress.types import LOG_OFF
from ..gateway.egress.addon_core import LOG_OFF, load_config
class EgressApplyError(RuntimeError):
@@ -19,14 +19,12 @@ from __future__ import annotations
import fcntl
import hashlib
import os
import shlex
import shutil
import subprocess
from contextlib import contextmanager
from pathlib import Path
from typing import Generator
from ... import resources
from ...log import die, info
from . import util
from .infra import FirecrackerInfraService
@@ -57,11 +55,6 @@ def _rootfs_digest(dockerfile: Path) -> str:
h = hashlib.sha256()
h.update(_dockerfile_hash(dockerfile).encode())
h.update(b"\0")
for name, value in resources.image_build_args(dockerfile).items():
h.update(name.encode())
h.update(b"=")
h.update(value.encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode())
return h.hexdigest()[:16]
@@ -154,13 +147,7 @@ def _build_in_infra(
if prep.returncode != 0:
die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}")
_send_dockerfile(key, ip, dockerfile, ctx)
_buildah_build(
key,
ip,
ctx,
tag,
resources.image_build_args(dockerfile),
)
_buildah_build(key, ip, ctx, tag)
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
_stream_rootfs(key, ip, tag, export_ctr, base)
finally:
@@ -197,26 +184,15 @@ def _send_dockerfile(private_key: Path, guest_ip: str, dockerfile: Path, ctx: st
f"{proc.stderr.decode(errors='replace').strip()}")
def _buildah_build(
private_key: Path,
guest_ip: str,
ctx: str,
tag: str,
build_args: dict[str, str],
) -> None:
def _buildah_build(private_key: Path, guest_ip: str, ctx: str, tag: str) -> None:
# Stream buildah's step-by-step output straight to our stderr (like the
# docker backend's `docker build`), so a long first build (base pull +
# apt/npm installs) shows live progress instead of a silent wait. The
# remote stderr is where buildah writes its `STEP i/n` lines.
info(f"buildah build {tag} in the infra VM (streaming output)")
arg_flags = " ".join(
f"--build-arg {shlex.quote(f'{name}={value}')}"
for name, value in build_args.items()
)
rc = _ssh_streamed(
private_key, guest_ip,
f"buildah build {_BUILD_FLAGS} {arg_flags} "
f"-t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
f"buildah build {_BUILD_FLAGS} -t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
timeout=_BUILD_TIMEOUT_SECONDS,
)
if rc != 0:
@@ -49,17 +49,9 @@ _ARTIFACT_FORMAT = "1"
# from its own generic package; the Dockerfiles baked into each differ (only the
# orchestrator rootfs carries buildah), so the versions are hashed separately.
ROLES = ("orchestrator", "gateway")
_BUILD_INPUTS = {
"orchestrator": (
"image-build-args.json",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
),
"gateway": (
"image-build-args.json",
"Dockerfile.gateway",
"requirements.gateway.lock",
),
_DOCKERFILES = {
"orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"),
"gateway": ("Dockerfile.gateway",),
}
_DEFAULT_BASE = "https://gitea.dideric.is"
@@ -109,7 +101,7 @@ def infra_artifact_version(
h.update(str(path.relative_to(repo_root)).encode())
h.update(b"\0")
h.update(path.read_bytes())
for name in _BUILD_INPUTS[role]:
for name in _DOCKERFILES[role]:
h.update(name.encode())
h.update(b"\0")
h.update((repo_root / name).read_bytes())
+1 -6
View File
@@ -133,15 +133,10 @@ def build_infra_images_with_docker() -> None:
root = str(resources.build_root())
docker_mod.build_image(
_ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator")
orchestrator_base = docker_mod.pinned_local_image_ref(_ORCHESTRATOR_IMAGE)
docker_mod.build_image(
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
docker_mod.build_image(
_ORCHESTRATOR_FC_IMAGE,
root,
dockerfile="Dockerfile.orchestrator.fc",
build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_base},
)
_ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc")
def build_rootfs_dir(role: str) -> Path:
+2 -6
View File
@@ -107,8 +107,7 @@ def _layer_nested_containers(
"""
if not plan.nested_containers:
return agent_image
pinned_base = container_mod.pinned_local_image_ref(agent_image)
derived = f"{pinned_base}{nested_containers_mod.IMAGE_SUFFIX}"
derived = f"{agent_image}{nested_containers_mod.IMAGE_SUFFIX}"
if plan.spec.image_policy == "cached":
if not container_mod.image_exists(derived):
die(
@@ -117,10 +116,7 @@ def _layer_nested_containers(
)
info(f"using cached nested-container image {derived!r}")
return derived
return nested_containers_mod.build_image(
pinned_base,
container_mod.build_image,
)
return nested_containers_mod.build_image(agent_image, container_mod.build_image)
@contextmanager
@@ -55,7 +55,7 @@ _GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
def build_image(
pinned_base: str,
base_image: str,
build: Callable[..., None],
) -> str:
"""Layer the nested-container tooling onto an already-built agent image.
@@ -66,15 +66,14 @@ def build_image(
# TODO(#394): replace this hand-rolled Dockerfile with a docker-layer
# abstraction once that infrastructure exists.
"""
image = f"{pinned_base}{IMAGE_SUFFIX}"
image = f"{base_image}{IMAGE_SUFFIX}"
init_script = Path(__file__).with_name("nested-containers-init.sh")
with tempfile.TemporaryDirectory(prefix="bot-bottle-nested-containers.") as tmp:
context = Path(tmp)
shutil.copy2(init_script, context / "nested-containers-init.sh")
(context / "Dockerfile").write_text(
"ARG DOCKER_CLI_BASE_IMAGE\n"
"FROM ${DOCKER_CLI_BASE_IMAGE} AS docker_cli\n"
f"FROM {pinned_base}\n"
"FROM docker:28-cli AS docker_cli\n"
f"FROM {base_image}\n"
"USER root\n"
"COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker\n"
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
+1 -49
View File
@@ -13,7 +13,6 @@ import time
from datetime import datetime, timezone
from typing import Iterable
from ... import resources
from ...log import die, info
@@ -61,13 +60,7 @@ def dns_server() -> str:
return _host_ipv4_dns() or _DEFAULT_DNS
def build_image(
ref: str,
context: str,
*,
dockerfile: str = "",
build_args: dict[str, str] | None = None,
) -> None:
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
"""Build an OCI image with Apple's BuildKit-backed `container build`.
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
@@ -90,13 +83,6 @@ def build_image(
if not os.path.isabs(dockerfile):
dockerfile = os.path.join(context, dockerfile)
args.extend(["-f", dockerfile])
effective_build_args = resources.image_build_args(
dockerfile,
context=context,
) if dockerfile else {}
effective_build_args.update(build_args or {})
for name, value in effective_build_args.items():
args.extend(["--build-arg", f"{name}={value}"])
args.append(context)
subprocess.run(args, check=True)
@@ -682,40 +668,6 @@ def image_id(ref: str) -> str:
raise AssertionError("unreachable")
def pinned_local_image_ref(ref: str) -> str:
"""Tag a local image with its complete content ID for a stable ``FROM``.
Agent images are immediately used as bases for the optional
nested-containers layer. A content-derived tag prevents another concurrent
build from moving the provider's ordinary ``:latest`` tag between those
two builds.
"""
image = image_id(ref)
digest = image.removeprefix("sha256:")
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
die(f"could not derive a local base tag from invalid image ID {image!r}")
repository = ref.split("@", 1)[0]
last_slash = repository.rfind("/")
last_colon = repository.rfind(":")
if last_colon > last_slash:
repository = repository[:last_colon]
pinned_ref = f"{repository}:sha256-{digest}"
result = subprocess.run(
[_CONTAINER, "image", "tag", image, pinned_ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"could not tag exact local image {image}: "
f"{(result.stderr or result.stdout or '').strip() or '<no detail>'}"
)
if image_id(pinned_ref) != image:
die(f"content-derived local tag {pinned_ref!r} did not resolve to {image}")
return pinned_ref
def image_created_at(ref: str) -> datetime | None:
"""Return the image creation timestamp as an aware UTC datetime, or None
when the field is absent or unparseable (e.g. FROM-scratch images, images
-124
View File
@@ -1,124 +0,0 @@
"""Backend-neutral preparation planner.
This module owns the shared transformation from a CLI ``BottleSpec`` to the
typed inputs consumed by a concrete backend's ``_resolve_plan``. Backend
classes retain only their validation/preflight/env hooks and their
backend-specific final resolution.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
from ..egress import EgressPlan
from ..env import ResolvedEnv, resolve_env
from ..git_gate import GitGate, GitGatePlan
from ..manifest import Manifest
from ..supervisor.plan import SupervisePlan
from ..workspace import workspace_plan
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
reject_nested_containers,
resolve_manifest_dockerfile,
write_launch_metadata,
)
if TYPE_CHECKING:
from .base import BottleSpec
class PreparationBackend(Protocol):
"""Backend hooks needed by the shared planner."""
name: str
supports_nested_containers: bool
def _validate(self, spec: BottleSpec) -> Manifest: ...
def _preflight(self) -> None: ...
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: ...
@dataclass(frozen=True)
class PreparedBottle:
"""Typed, backend-neutral result of shared launch preparation."""
manifest: Manifest
slug: str
resolved_env: ResolvedEnv
agent_provision_plan: AgentProvisionPlan
egress_plan: EgressPlan
git_gate_plan: GitGatePlan
supervise_plan: SupervisePlan | None
class BottlePreparationPlanner:
"""Run the common, side-effect-limited part of bottle preparation."""
def __init__(self, backend: PreparationBackend) -> None:
self._backend = backend
def prepare(self, spec: BottleSpec) -> PreparedBottle:
backend = self._backend
# These are deliberately protected backend hooks: only this shared
# planner orchestrates them, while concrete backends provide the
# implementation.
manifest = backend._validate(spec) # pylint: disable=protected-access
if not backend.supports_nested_containers:
reject_nested_containers(backend.name, manifest)
backend._preflight() # pylint: disable=protected-access
manifest = GitGate().preflight_host_keys(
manifest,
headless=spec.headless,
home_md=spec.manifest.home_md,
)
bottle = manifest.bottle
provider_config = bottle.agent_provider
provider = get_provider(provider_config.template)
resolved_env = resolve_env(manifest)
workspace = workspace_plan(spec, guest_home=provider.guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=backend.name)
dockerfile = (
resolve_manifest_dockerfile(provider_config.dockerfile, spec)
if provider_config.dockerfile
else str(provider.dockerfile)
)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
provision = build_agent_provision_plan(
template=provider_config.template,
dockerfile=dockerfile,
state_dir=agent_dir,
instance_name=f"bot-bottle-{slug}",
prompt_file=prompt_file,
guest_env=backend._build_guest_env( # pylint: disable=protected-access
resolved_env
),
forward_host_credentials=provider_config.forward_host_credentials,
auth_token=provider_config.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace.workdir,
label=spec.label,
color=spec.color,
provider_settings=provider_config.settings,
)
provision = merge_provision_env_vars(provision)
return PreparedBottle(
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=provision,
egress_plan=prepare_egress(bottle, slug, provision),
git_gate_plan=prepare_git_gate(bottle, slug),
supervise_plan=prepare_supervise(bottle, slug),
)
+2 -2
View File
@@ -30,7 +30,6 @@ from ..log import die
from ..manifest import Manifest, ManifestBottle
from ..supervisor.plan import SupervisePlan
from ..orchestrator.supervisor import Supervisor
from ..util import slugify
from . import BottleSpec
@@ -45,7 +44,8 @@ def mint_slug(spec: BottleSpec) -> str:
if spec.identity:
return spec.identity
if spec.label:
return slugify(spec.label)
from .docker import util as docker_mod
return docker_mod.slugify(spec.label)
return bottle_identity(spec.agent_name)
+9 -8
View File
@@ -25,11 +25,12 @@ from typing import Callable
from ...agent_provider import get_provider, runtime_for
from ...backend import (
Bottle,
BottlePlan,
BottleSpec,
enumerate_active_agents,
get_bottle_backend,
)
from ...backend.docker import util as docker_mod
from ...backend.docker.bottle_plan import DockerBottlePlan
from ...bottle_state import (
cleanup_state,
is_preserved,
@@ -39,7 +40,7 @@ from ...image_cache import StaleImageError
from ...log import info, die
from ...manifest import Manifest, ManifestIndex
from ..constants import PROG
from ...util import read_tty_line, slugify
from ...util import read_tty_line
from .. import tui
@@ -256,10 +257,10 @@ def _uniquify_label_headless(label: str) -> str:
logging the chosen label. Orchestrators fire-and-forget many bottles,
so silently picking a free name beats erroring on every collision."""
active_slugs = {a.slug for a in enumerate_active_agents()}
if slugify(label) not in active_slugs:
if docker_mod.slugify(label) not in active_slugs:
return label
n = 2
while slugify(f"{label}-{n}") in active_slugs:
while docker_mod.slugify(f"{label}-{n}") in active_slugs:
n += 1
chosen = f"{label}-{n}"
info(f"label '{label}' already in use; using '{chosen}'")
@@ -273,11 +274,11 @@ def prepare_with_preflight(
spec: BottleSpec,
*,
stage_dir: Path,
render_preflight: Callable[[BottlePlan, str], None],
render_preflight: Callable[[DockerBottlePlan, str], None],
prompt_yes: Callable[[], bool],
dry_run: bool = False,
backend_name: str | None = None,
) -> tuple[BottlePlan | None, str]:
) -> tuple[DockerBottlePlan | None, str]:
"""Run `backend.prepare`, render the preflight summary via the
injected callable, prompt y/N via the injected callable.
@@ -404,7 +405,7 @@ def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
in use among running bottles. Passes through unchanged when no
collision is found on the first check."""
while True:
slug_candidate = slugify(label)
slug_candidate = docker_mod.slugify(label)
active_slugs = {a.slug for a in enumerate_active_agents()}
if slug_candidate not in active_slugs:
return label, color
@@ -431,7 +432,7 @@ def _select_image_policy() -> str | None:
def _text_render_preflight():
def _render(plan: BottlePlan, backend_name: str) -> None:
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
print(file=sys.stderr)
print(f"backend: {backend_name}", file=sys.stderr)
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
+19 -22
View File
@@ -368,7 +368,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
elif key in (curses.KEY_UP, ord("k")):
selected = max(selected - 1, 0)
elif key in (curses.KEY_ENTER, 10, 13):
status_line = _detail_view(stdscr, qp, green_attr=green_attr)
_detail_view(stdscr, qp, green_attr=green_attr)
elif key == ord("a"):
try:
status_line = _approve_from_tui(stdscr, qp)
@@ -456,7 +456,7 @@ def _detail_view(
qp: QueuedProposal,
*,
green_attr: int = 0,
) -> str: # pragma: no cover
) -> None: # pragma: no cover
"""Render the full proposal. Scrollable. Press q to return."""
lines = _detail_lines(qp, green_attr=green_attr)
offset = 0
@@ -473,7 +473,7 @@ def _detail_view(
stdscr.refresh()
key = stdscr.getch()
if key in (ord("q"), 27):
return ""
return
if key in (curses.KEY_DOWN, ord("j")):
offset = min(offset + 1, max(0, len(lines) - 1))
elif key in (curses.KEY_UP, ord("k")):
@@ -484,34 +484,31 @@ def _detail_view(
offset = max(0, len(lines) - 1)
elif key == ord("a"):
try:
return _approve_from_tui(stdscr, qp)
except ApplyError as exc:
return f"apply failed: {exc}"
_approve_from_tui(stdscr, qp)
except ApplyError:
pass
return
elif key == ord("m"):
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
return f"modify unavailable for {qp.proposal.tool}"
return
edited = _modify(stdscr, qp)
if edited is None:
return "modify aborted (no change)"
try:
return _approve_from_tui(
stdscr, qp, final_file=edited,
notes="operator modified before approving",
)
except ApplyError as exc:
return f"apply failed: {exc}"
if edited is not None:
try:
_approve_from_tui(
stdscr, qp, final_file=edited,
notes="operator modified before approving",
)
except ApplyError:
pass
return
elif key == ord("r"):
reason = _prompt(stdscr, "reject reason: ")
if reason:
reject(qp, reason=reason)
return f"rejected {qp.proposal.tool} for [{qp.label}]"
return "reject aborted (empty reason)"
return
def _modify(
stdscr: "curses._CursesWindow", # type: ignore
qp: QueuedProposal,
) -> str | None: # pragma: no cover
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
suffix = _suffix_for_tool(qp.proposal.tool)
curses.endwin()
+6 -32
View File
@@ -16,8 +16,6 @@ import os
import sys
from typing import Any, Optional
from ..log import debug
def filter_multiselect(
items: list[str],
@@ -44,11 +42,7 @@ def filter_multiselect(
try:
tty_fd = open(tty_path, "r+b", buffering=0)
except OSError as exc:
debug(
"multi-select unavailable; treating it as cancellation",
context={"error_type": type(exc).__name__, "tty": tty_path},
)
except OSError:
return None
try:
@@ -79,11 +73,7 @@ def filter_select(
try:
tty_fd = open(tty_path, "r+b", buffering=0)
except OSError as exc:
debug(
"filter-select unavailable; treating it as cancellation",
context={"error_type": type(exc).__name__, "tty": tty_path},
)
except OSError:
return None
try:
@@ -139,11 +129,7 @@ def _run_picker(items: list[str], *, title: str, tty_fd: int) -> Optional[str]:
curses.nocbreak()
curses.echo()
curses.endwin()
except Exception as exc: # noqa: W0718 — curses can raise many error types
debug(
"filter-select display failed; treating it as cancellation",
context={"error_type": type(exc).__name__},
)
except Exception: # noqa: W0718 — curses can raise many error types
return None
finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment]
@@ -306,11 +292,7 @@ def _run_multiselect(
curses.nocbreak()
curses.echo()
curses.endwin()
except Exception as exc: # noqa: W0718
debug(
"multi-select display failed; treating it as cancellation",
context={"error_type": type(exc).__name__},
)
except Exception: # noqa: W0718
return None
finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment]
@@ -576,21 +558,13 @@ def name_color_modal(
"""
try:
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
except OSError as exc:
debug(
"name/color picker unavailable; using defaults",
context={"error_type": type(exc).__name__, "tty": tty_path},
)
except OSError:
return default_label, ""
try:
fd_dup = os.dup(tty_fd.fileno())
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
debug(
"name/color picker failed; using defaults",
context={"error_type": type(exc).__name__},
)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return default_label, ""
finally:
tty_fd.close()
+10 -20
View File
@@ -8,17 +8,9 @@
# Layer ordering is deliberate: the npm install lives in its own layer so
# changes to the rest of the repo (or to the CMD) don't bust it.
# Version-qualified Node LTS, pinned to its multi-architecture manifest.
ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
/etc/apt/sources.list.d/debian.sources
# Current Node LTS; slim variant keeps the image small while still
# providing apt-get for any future additions.
FROM node:22-trixie-slim
# Install runtime system deps. claude-code shells out to git for several
# features (status checks, commits, PR creation) — without git in the
@@ -28,7 +20,7 @@ RUN sed -i \
# HTTPS_PROXY-aware tool (curl itself, plus anything that shells out
# to it) works against egress's bumped TLS without the agent needing
# local DNS.
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
@@ -43,17 +35,15 @@ RUN apt-get -o Acquire::Check-Valid-Until=false update \
# (claude-code is a Node CLI), but is convenient for the agent to
# shell out to for ad-hoc scripts. Kept on its own layer so it can
# be moved to a downstream image if the base ever needs to shrink.
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Install from the committed npm lock. `npm ci` verifies every registry
# artifact against its lockfile integrity and refuses dependency drift.
COPY bot_bottle/contrib/claude/package.json \
bot_bottle/contrib/claude/package-lock.json /opt/claude/
RUN cd /opt/claude \
&& npm ci --omit=dev --no-fund --no-audit \
&& ln -s /opt/claude/node_modules/.bin/claude /usr/local/bin/claude \
# Install claude-code globally. Pinned to the version verified in the v1
# build (`claude --version` returns 2.1.126). Bump deliberately when
# rolling forward; an unpinned install would mean rebuilds silently pick
# up new behavior.
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
&& npm cache clean --force
# Git reads both ~/.gitconfig and ~/.config/git/config. Keep its XDG config
-140
View File
@@ -1,140 +0,0 @@
{
"name": "bot-bottle-claude-image",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bot-bottle-claude-image",
"dependencies": {
"@anthropic-ai/claude-code": "2.1.172"
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.172.tgz",
"integrity": "sha512-SfwC+5fQcmNbvvm+1vLiZbfUxt0PQz9lbXapj9+FI+XY/2e+3zgteBM4JFEXBjULZj1DtZXHmKtAROUrMM9GZg==",
"hasInstallScript": true,
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "bin/claude.exe"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-code-darwin-arm64": "2.1.172",
"@anthropic-ai/claude-code-darwin-x64": "2.1.172",
"@anthropic-ai/claude-code-linux-arm64": "2.1.172",
"@anthropic-ai/claude-code-linux-arm64-musl": "2.1.172",
"@anthropic-ai/claude-code-linux-x64": "2.1.172",
"@anthropic-ai/claude-code-linux-x64-musl": "2.1.172",
"@anthropic-ai/claude-code-win32-arm64": "2.1.172",
"@anthropic-ai/claude-code-win32-x64": "2.1.172"
}
},
"node_modules/@anthropic-ai/claude-code-darwin-arm64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.172.tgz",
"integrity": "sha512-pBRgDo8PAgbt2aE4oc6ZrKdOa/Ax36RAduhLCaI8NWD3a0RDb5mETzQciQLwnuenk0bs27vIRh9Yg1jAYG/0+A==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-code-darwin-x64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.172.tgz",
"integrity": "sha512-vSgibgeCyvCFiLJSXu/sgcd3L/tUjSiS/tfS9rJLXUjElw8satMJhA5pqPUiBmKMflOWKMufbZgzXvLx+OhvFw==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-code-linux-arm64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.172.tgz",
"integrity": "sha512-Ql7AbyaXnlA6NwDUaQGO7ZZis21rMjYjbKzpksMCvY35CmsJqanYgbYSn/rE83u/tZpKo+NqOv+bWEDSzsZ02w==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-arm64-musl": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.172.tgz",
"integrity": "sha512-Pa9mGGmp8QCRC2j1cgcWRRkwsK1x4bPsS3CcLRd936Op0k5et62tD3qIYhUPQOIxeuxe9Tt/y7lX1cx7JTDxyA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-x64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.172.tgz",
"integrity": "sha512-RYCY9EHkmtoAlwBKcWRzGIhuus+GM2CIVCfU81cTQAwDcCHunoBeFn3NqAcFV1VKb4dk9TRarAmQcWMKJrpPig==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-x64-musl": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.172.tgz",
"integrity": "sha512-1NflAnV/MqIlD4rzlGDVsJgGK2xJ2ldVd5pkcbu7PsDJBW5dzKYVa4PmD0715K8Yiji8jQovh/nhIo6gYyTfVQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-win32-arm64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.172.tgz",
"integrity": "sha512-Gj8mIbHDDSGWnoriqB1Jt1uH6cvNBFDQBtIYarCcv0fs+QGCEP6qm2GIaKqxAbwkUaLT0sCW/7+ukvkNdSltuQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/claude-code-win32-x64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.172.tgz",
"integrity": "sha512-OkheFwagiCEKaO2Sb0j3JTO1NrcR3zTlFik/AN+yQPefhUIGP6vHQDbBG5ksuFe+Dyd9s+P95OUh8WDhccb+Ng==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
}
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"name": "bot-bottle-claude-image",
"private": true,
"dependencies": {
"@anthropic-ai/claude-code": "2.1.172"
}
}
+8 -40
View File
@@ -3,22 +3,9 @@
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed for that user.
ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
FROM node:22-trixie-slim
# Remote-control requires the standalone package layout. Keep this exact release
# in sync with the committed upstream archive checksums.
ARG CODEX_VERSION=0.145.0
ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
/etc/apt/sources.list.d/debian.sources
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
@@ -32,7 +19,7 @@ RUN apt-get -o Acquire::Check-Valid-Until=false update \
# (codex is a Node CLI), but is convenient for the agent to shell
# out to for ad-hoc scripts. Kept on its own layer so it can be
# moved to a downstream image if the base ever needs to shrink.
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
@@ -43,29 +30,10 @@ WORKDIR /home/node
ENV PATH="/home/node/.local/bin:${PATH}"
# Install the exact standalone release archive selected by the target
# architecture. The checksum list is copied from the immutable upstream release
# and committed so a rebuild cannot silently accept changed release bytes.
COPY --chown=node:node bot_bottle/contrib/codex/codex-package_SHA256SUMS /tmp/codex-package_SHA256SUMS
RUN case "$(dpkg --print-architecture)" in \
amd64) codex_target=x86_64-unknown-linux-musl ;; \
arm64) codex_target=aarch64-unknown-linux-musl ;; \
*) echo "unsupported Codex architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac \
&& codex_asset="codex-package-${codex_target}.tar.gz" \
&& codex_sha256="$(awk -v asset="${codex_asset}" '$2 == asset { print $1 }' /tmp/codex-package_SHA256SUMS)" \
&& test -n "${codex_sha256}" \
&& curl -fsSL \
"https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/${codex_asset}" \
-o "/tmp/${codex_asset}" \
&& echo "${codex_sha256} /tmp/${codex_asset}" | sha256sum -c - \
&& codex_release="/home/node/.codex/packages/standalone/releases/${CODEX_VERSION}-${codex_target}" \
&& mkdir -p "${codex_release}" /home/node/.local/bin \
&& tar -xzf "/tmp/${codex_asset}" -C "${codex_release}" \
&& ln -s bin/codex "${codex_release}/codex" \
&& ln -s "${codex_release}" /home/node/.codex/packages/standalone/current \
&& ln -s /home/node/.codex/packages/standalone/current/bin/codex /home/node/.local/bin/codex \
&& rm "/tmp/${codex_asset}" \
&& test "$(codex --version)" = "codex-cli ${CODEX_VERSION}"
# Remote-control support requires the standalone Codex install layout
# under ~/.codex/packages/standalone/current. The npm package can run
# the TUI, but remote-control commands expect this installer-owned path.
RUN mkdir -p /home/node/.codex \
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
CMD ["codex"]
@@ -1,2 +0,0 @@
54f79a05aba6f9abf8ef988abcae8bf2fcefba20beb549b4ff2b3acdb2cb6f54 codex-package-aarch64-unknown-linux-musl.tar.gz
71a28d362c96ac9829bf8203a2c71be451aeb726adb843167fdaf0eae8fe7dd9 codex-package-x86_64-unknown-linux-musl.tar.gz
+11 -22
View File
@@ -2,18 +2,9 @@
#
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
FROM node:22-trixie-slim
ARG DEBIAN_SNAPSHOT=20260724T000000Z
RUN sed -i \
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
/etc/apt/sources.list.d/debian.sources
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
@@ -24,10 +15,13 @@ RUN apt-get -o Acquire::Check-Valid-Until=false update \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \
&& npm cache clean --force
RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git \
&& mkdir -p /home/node/.pi/agent \
/home/node/.pi/context-mode/sessions \
@@ -40,15 +34,10 @@ RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git
USER node
WORKDIR /home/node
# Pi discovers npm packages from its agent directory. Installing the CLI and
# extensions together from the committed lock makes all direct and transitive
# package versions deterministic, with npm integrity verification.
COPY --chown=node:node bot_bottle/contrib/pi/package.json \
bot_bottle/contrib/pi/package-lock.json /home/node/.pi/agent/
RUN cd /home/node/.pi/agent \
&& npm ci --omit=dev --no-fund --no-audit \
&& npm cache clean --force
ENV PATH="/home/node/.pi/agent/node_modules/.bin:${PATH}"
RUN pi install npm:@harms-haus/pi-cwd \
&& pi install npm:pi-web-access \
&& pi install npm:context-mode \
&& pi install npm:pi-subagents \
&& pi install npm:pi-mcp-adapter
CMD ["pi"]
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
{
"name": "bot-bottle-pi-image",
"private": true,
"dependencies": {
"@earendil-works/pi-coding-agent": "0.81.1",
"@earendil-works/pi-agent-core": "0.81.1",
"@earendil-works/pi-ai": "0.81.1",
"@earendil-works/pi-tui": "0.81.1",
"@harms-haus/pi-cwd": "1.0.0",
"context-mode": "1.0.169",
"pi-mcp-adapter": "2.11.0",
"pi-subagents": "0.35.1",
"pi-web-access": "0.13.0"
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ..gateway.egress.types import Route
from ..gateway.egress.addon_core import Route
@dataclass(frozen=True)
@@ -19,7 +19,7 @@ class EgressRoute(Route):
"""Host-side extension of the addon's `Route`.
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
from the gateway's wire `Route` — those are the fields that cross the
from `egress_addon_core.Route` those are the fields that cross the
YAML wire into the gateway. The fields below are host-only and
are never serialised to the addon.
+2 -2
View File
@@ -14,8 +14,8 @@ import secrets
from pathlib import Path
from typing import TYPE_CHECKING
from ..gateway.egress.dlp_config import ON_MATCH_REDACT
from ..gateway.egress.types import (
from ..gateway.egress.addon_core import (
ON_MATCH_REDACT,
HeaderMatch as CoreHeaderMatch,
MatchEntry as CoreMatchEntry,
PathMatch as CorePathMatch,
+11 -17
View File
@@ -17,34 +17,28 @@ from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=
from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens, strip_crlf
from bot_bottle.gateway.egress.dlp_config import (
from bot_bottle.gateway.egress.addon_core import (
LOG_BLOCKS,
LOG_FULL,
DEFAULT_OUTBOUND_ON_MATCH,
ON_MATCH_BLOCK,
ON_MATCH_REDACT,
)
from bot_bottle.gateway.egress.context import resolve_client_context
from bot_bottle.gateway.egress.dlp import (
Config,
Route,
ScanResult,
build_inbound_scan_text,
build_outbound_scan_text,
build_token_allow_payload,
outbound_scan_headers,
scan_inbound,
scan_outbound,
)
from bot_bottle.gateway.egress.matching import (
decide,
decide_git_fetch,
is_git_fetch_request,
is_git_push_request,
match_route,
)
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
from bot_bottle.gateway.egress.types import (
LOG_BLOCKS,
LOG_FULL,
Config,
Route,
ScanResult,
resolve_client_context,
outbound_scan_headers,
route_to_yaml_dict,
scan_inbound,
scan_outbound,
)
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.supervisor.types import (
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
"""Fail-closed resolution of a client's policy and egress credentials."""
from __future__ import annotations
import typing
from ...log import debug
from .types import Config
DENY_UNATTRIBUTED = (
"egress: this request was not attributed to any bottle, so no egress policy "
"applies and every host is denied. Either the bottle's registry row is "
"missing/ambiguous (torn down, or another bottle claimed its source IP), or "
"the request carried no matching identity token — check that the caller's "
"proxy URL includes it. This is not an allowlist problem."
)
DENY_UNPARSEABLE = (
"egress: this bottle's egress policy could not be parsed, so it is being "
"treated as deny-all. Fix the bottle's egress.routes; every host is denied "
"until it loads."
)
DENY_RESOLVER_ERROR = (
"egress: the orchestrator could not be reached to resolve this bottle's "
"egress policy, so every host is denied (fail-closed). Check that the "
"control plane is up; this is not an allowlist problem."
)
class PolicyResolverLike(typing.Protocol):
def resolve(self, source_ip: str, identity_token: str = ...) -> str | None: ...
class ContextResolverLike(typing.Protocol):
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = ...,
) -> tuple[str | None, str | None, dict[str, str]]: ...
def _config_from_policy(policy: str | None) -> Config:
# Local import keeps schema parsing independent of resolver protocols.
from .schema import load_config
if not policy:
return Config(routes=(), deny_reason=DENY_UNATTRIBUTED)
try:
return load_config(policy)
except ValueError:
return Config(routes=(), deny_reason=DENY_UNPARSEABLE)
def resolve_client_config(
resolver: PolicyResolverLike, client_ip: str, identity_token: str = "",
) -> Config:
try:
policy = resolver.resolve(client_ip, identity_token)
except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
debug(
"egress policy resolution failed; applying deny-all",
context={"error_type": type(exc).__name__},
)
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
return _config_from_policy(policy)
def resolve_client_context(
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
) -> tuple[Config, str, dict[str, str]]:
try:
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
client_ip, identity_token)
except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
debug(
"egress context resolution failed; applying deny-all",
context={"error_type": type(exc).__name__},
)
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
return _config_from_policy(policy), (bottle_id or ""), tokens
-99
View File
@@ -1,99 +0,0 @@
"""DLP scan dispatch and safe proposal rendering for egress requests."""
from __future__ import annotations
import typing
from .types import Route, ScanResult
def build_outbound_scan_text(host: str, path: str, query: str,
headers: typing.Mapping[str, str], body: str) -> str:
parts = [host, path]
if query:
parts.append(query)
parts.extend(f"{name}: {value}" for name, value in headers.items())
if body:
parts.append(body)
return "\n".join(parts)
def outbound_scan_headers(route: Route, headers: typing.Mapping[str, str]) -> dict[str, str]:
"""Drop agent Authorization when the route injects gateway-owned auth."""
skip_auth = bool(route.auth_scheme and route.token_env)
return {name: value for name, value in headers.items()
if not (skip_auth and name.lower() == "authorization")}
def build_inbound_scan_text(headers: typing.Mapping[str, str], body: str) -> str:
parts = [f"{name}: {value}" for name, value in headers.items()]
if body:
parts.append(body)
return "\n".join(parts)
def _enabled(configured: tuple[str, ...] | None, name: str) -> bool:
return configured is None or name in configured
def scan_outbound(route: Route, body: str | bytes, environ: typing.Mapping[str, str], *,
safe_tokens: typing.AbstractSet[str] | None = None,
crlf_text: str | None = None) -> ScanResult | None:
if not route.inspect:
return None
try:
from dlp_detectors import ( # type: ignore[import-not-found]
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
except ImportError: # pragma: no cover - gateway's flat module path
from .dlp_detectors import (
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
if isinstance(body, bytes):
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
text = body.decode("latin-1")
else:
text = body
result = scan_crlf_injection(text if crlf_text is None else crlf_text)
if result is not None:
return result
if _enabled(route.outbound_detectors, "token_patterns"):
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
if result is not None:
return result
if _enabled(route.outbound_detectors, "known_secrets"):
extra = tuple(prefix for prefix in environ.get(
"BOT_BOTTLE_SENSITIVE_PREFIXES", "").split(",") if prefix)
result = scan_known_secrets(text, location="body", env=environ,
sensitive_prefixes=("EGRESS_TOKEN_",) + extra,
safe_tokens=safe_tokens)
if result is not None:
return result
if route.outbound_detectors is not None and "entropy" in route.outbound_detectors:
return scan_entropy(text, location="body")
return None
def build_token_allow_payload(host: str, method: str, path: str, result: ScanResult) -> str:
"""Render redacted operator context; the raw matched secret is excluded."""
lines = [
"egress blocked an outbound request carrying a detected token",
f"host: {host}", f"method: {method}", f"path: {path}",
f"detector: {result.reason}",
]
if result.context:
lines.append(f"context: {result.context}")
return "\n".join(lines) + "\n"
def scan_inbound(route: Route, body: str | bytes) -> ScanResult | None:
if not route.inspect:
return None
try:
from dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
except ImportError: # pragma: no cover - gateway's flat module path
from .dlp_detectors import scan_naive_injection
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
if _enabled(route.inbound_detectors, "naive_injection_detection"):
return scan_naive_injection(text)
return None
+1 -1
View File
@@ -19,7 +19,7 @@ from math import log2
from collections import Counter
from urllib.parse import quote as url_quote
from .types import ScanResult
from .addon_core import ScanResult
# ---------------------------------------------------------------------------
-112
View File
@@ -1,112 +0,0 @@
"""Route matching and request-policy decisions for the egress gateway."""
from __future__ import annotations
import typing
from .types import Decision, MatchEntry, PathMatch, Route
def _path_matches(pm: PathMatch, request_path: str) -> bool:
if pm.type == "exact":
return request_path == pm.value
if pm.type == "prefix":
if request_path == pm.value:
return True
if not pm.value.endswith("/"):
return request_path.startswith(pm.value + "/")
return request_path.startswith(pm.value)
return (
pm.type == "regex"
and pm.compiled is not None
and pm.compiled.search(request_path) is not None
)
def _entry_matches(
entry: MatchEntry, request_path: str, request_method: str,
request_headers: typing.Mapping[str, str],
) -> bool:
if entry.paths and not any(_path_matches(pm, request_path) for pm in entry.paths):
return False
if entry.methods and request_method.upper() not in entry.methods:
return False
for match in entry.headers:
value = request_headers.get(match.name.lower())
if value is None:
return False
if match.type == "exact" and value != match.value:
return False
if match.type == "regex" and (
match.compiled is None or match.compiled.search(value) is None
):
return False
return True
def evaluate_matches(
route: Route, request_path: str, request_method: str = "GET",
request_headers: typing.Mapping[str, str] | None = None,
) -> bool:
"""Return whether a request satisfies a route's optional match entries."""
if not route.matches:
return True
return any(_entry_matches(entry, request_path, request_method, request_headers or {})
for entry in route.matches)
def is_git_push_request(path: str, query: str) -> bool:
return path.endswith("/git-receive-pack") or (
path.endswith("/info/refs") and any(
pair.partition("=") == ("service", "=", "git-receive-pack")
for pair in query.split("&")
)
)
def is_git_fetch_request(path: str, query: str) -> bool:
return path.endswith("/git-upload-pack") or (
path.endswith("/info/refs") and any(
pair.partition("=") == ("service", "=", "git-upload-pack")
for pair in query.split("&")
)
)
def match_route(routes: typing.Sequence[Route], request_host: str) -> Route | None:
target = request_host.lower()
return next((route for route in routes if route.host.lower() == target), None)
def decide(
routes: typing.Sequence[Route], request_host: str, request_path: str,
environ: typing.Mapping[str, str], *, request_method: str = "GET",
request_headers: typing.Mapping[str, str] | None = None, deny_reason: str = "",
) -> Decision:
route = match_route(routes, request_host)
if route is None:
return Decision("block", deny_reason or (
f"egress: host {request_host!r} is not in the bottle's egress.routes "
"allowlist. Declare a route for it or remove the request."))
if not evaluate_matches(route, request_path, request_method, request_headers):
return Decision("block", (
f"egress: request {request_method} {request_path!r} does not match any "
f"entry in matches for {route.host!r}"))
if route.auth_scheme and route.token_env:
token = environ.get(route.token_env, "")
if not token:
return Decision("block", (
f"egress: route for {route.host!r} declared auth but env var "
f"{route.token_env!r} is unset"))
return Decision("forward", inject_authorization=f"{route.auth_scheme} {token}")
return Decision("forward")
def decide_git_fetch(routes: typing.Sequence[Route], request_host: str) -> Decision:
route = match_route(routes, request_host)
if route is not None and route.git_fetch:
return Decision("forward")
return Decision("block", (
"egress: git fetch/clone over HTTPS is not allowed by default; use git-gate "
"for declared repos or set egress.routes[].git.fetch=true for explicit "
"read-only HTTPS Git access."))
-349
View File
@@ -1,349 +0,0 @@
"""Egress policy schema parsing and serialization (PRD 0017 / 0053)."""
from __future__ import annotations
import re
import typing
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
from .dlp_config import parse_inspect_block
from .types import (
HEADER_MATCH_TYPES,
LOG_BLOCKS,
LOG_FULL,
LOG_OFF,
PATH_MATCH_TYPES,
VALID_METHODS,
Config,
HeaderMatch,
MatchEntry,
PathMatch,
Route,
)
# Parsing
# ---------------------------------------------------------------------------
def _parse_path_match(idx: int, j: int, raw: object) -> PathMatch:
label = f"route[{idx}] matches paths[{j}]"
if not isinstance(raw, dict):
raise ValueError(f"{label}: must be an object")
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
ptype = raw_dict.get("type", "prefix")
if not isinstance(ptype, str) or ptype not in PATH_MATCH_TYPES:
raise ValueError(
f"{label}: 'type' must be one of {', '.join(PATH_MATCH_TYPES)} "
f"(got {ptype!r})"
)
value = raw_dict.get("value")
if not isinstance(value, str) or not value:
raise ValueError(f"{label}: 'value' must be a non-empty string")
if ptype in ("exact", "prefix") and not value.startswith("/"):
raise ValueError(
f"{label}: value {value!r} must start with '/' for "
f"type {ptype!r}"
)
compiled: re.Pattern[str] | None = None
if ptype == "regex":
try:
compiled = re.compile(value)
except re.error as e:
raise ValueError(
f"{label}: regex {value!r} failed to compile: {e}"
) from e
for k in raw_dict:
if k not in ("type", "value"):
raise ValueError(f"{label}: unknown key {k!r}")
return PathMatch(type=ptype, value=value, compiled=compiled)
def _parse_header_match(idx: int, j: int, raw: object) -> HeaderMatch:
label = f"route[{idx}] matches headers[{j}]"
if not isinstance(raw, dict):
raise ValueError(f"{label}: must be an object")
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
name = raw_dict.get("name")
if not isinstance(name, str) or not name:
raise ValueError(f"{label}: 'name' must be a non-empty string")
value = raw_dict.get("value")
if not isinstance(value, str):
raise ValueError(f"{label}: 'value' must be a string")
htype = raw_dict.get("type", "exact")
if not isinstance(htype, str) or htype not in HEADER_MATCH_TYPES:
raise ValueError(
f"{label}: 'type' must be one of {', '.join(HEADER_MATCH_TYPES)} "
f"(got {htype!r})"
)
compiled: re.Pattern[str] | None = None
if htype == "regex":
try:
compiled = re.compile(value)
except re.error as e:
raise ValueError(
f"{label}: regex {value!r} failed to compile: {e}"
) from e
for k in raw_dict:
if k not in ("name", "value", "type"):
raise ValueError(f"{label}: unknown key {k!r}")
return HeaderMatch(name=name, value=value, type=htype, compiled=compiled)
def _parse_match_entry(idx: int, k: int, raw: object) -> MatchEntry:
label = f"route[{idx}] matches[{k}]"
if not isinstance(raw, dict):
raise ValueError(f"{label}: must be an object")
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
paths: tuple[PathMatch, ...] = ()
paths_raw = raw_dict.get("paths")
if paths_raw is not None:
if not isinstance(paths_raw, list):
raise ValueError(f"{label}: 'paths' must be a list")
paths_list = typing.cast(list[object], paths_raw)
paths = tuple(_parse_path_match(idx, j, p) for j, p in enumerate(paths_list))
methods: tuple[str, ...] = ()
methods_raw = raw_dict.get("methods")
if methods_raw is not None:
if not isinstance(methods_raw, list):
raise ValueError(f"{label}: 'methods' must be a list")
methods_list = typing.cast(list[object], methods_raw)
normalised: list[str] = []
for j, m in enumerate(methods_list):
if not isinstance(m, str):
raise ValueError(f"{label}: methods[{j}] must be a string")
upper = m.upper()
if upper not in VALID_METHODS:
raise ValueError(
f"{label}: methods[{j}] {m!r} is not a valid HTTP method"
)
normalised.append(upper)
methods = tuple(normalised)
headers: tuple[HeaderMatch, ...] = ()
headers_raw = raw_dict.get("headers")
if headers_raw is not None:
if not isinstance(headers_raw, list):
raise ValueError(f"{label}: 'headers' must be a list")
headers_list = typing.cast(list[object], headers_raw)
headers = tuple(
_parse_header_match(idx, j, h) for j, h in enumerate(headers_list)
)
for key in raw_dict:
if key not in ("paths", "methods", "headers"):
raise ValueError(f"{label}: unknown key {key!r}")
return MatchEntry(paths=paths, methods=methods, headers=headers)
def parse_routes(payload: object) -> tuple[Route, ...]:
if not isinstance(payload, dict):
raise ValueError("routes payload: top-level must be an object")
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
raw: object = payload_dict.get("routes")
if not isinstance(raw, list):
raise ValueError("routes payload: 'routes' must be a list")
raw_list: list[object] = typing.cast(list[object], raw)
out: list[Route] = []
for i, r in enumerate(raw_list):
out.append(_parse_one(i, r))
return tuple(out)
def _parse_one(idx: int, raw: object) -> Route:
label = f"route[{idx}]"
if not isinstance(raw, dict):
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
host: object = raw_dict.get("host")
if not isinstance(host, str) or not host:
raise ValueError(f"{label}: 'host' must be a non-empty string")
legacy_flat = "inspect" not in raw_dict
inspect_raw = raw_dict.get("inspect", {})
if inspect_raw is False:
inspect = False
settings: dict[str, object] = {}
elif isinstance(inspect_raw, dict):
inspect = True
settings = (
{k: v for k, v in raw_dict.items() if k != "host"}
if legacy_flat
else typing.cast(dict[str, object], inspect_raw)
)
legacy_dlp = settings.pop("dlp", None)
if isinstance(legacy_dlp, dict):
settings.update(typing.cast(dict[str, object], legacy_dlp))
elif legacy_dlp is not None:
raise ValueError(
f"{label} ({host}): legacy 'dlp' must be an object"
)
else:
raise ValueError(f"{label} ({host}): 'inspect' must be false or an object")
# matches
matches: tuple[MatchEntry, ...] = ()
matches_raw = settings.get("matches")
if matches_raw is not None:
if not isinstance(matches_raw, list):
raise ValueError(f"{label} ({host}): 'matches' must be a list")
matches_list = typing.cast(list[object], matches_raw)
matches = tuple(
_parse_match_entry(idx, k, m) for k, m in enumerate(matches_list)
)
# auth (unchanged wire format)
auth_scheme: object = settings.get("auth_scheme", "")
token_env: object = settings.get("token_env", "")
if not isinstance(auth_scheme, str):
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
if not isinstance(token_env, str):
raise ValueError(f"{label} ({host}): 'token_env' must be a string")
if bool(auth_scheme) != bool(token_env):
raise ValueError(
f"{label} ({host}): 'auth_scheme' and 'token_env' must be both "
f"set or both empty (got auth_scheme={auth_scheme!r}, "
f"token_env={token_env!r})"
)
# git-over-HTTPS policy
git_fetch = False
git_raw = settings.get("git")
if git_raw is not None:
if not isinstance(git_raw, dict):
raise ValueError(f"{label} ({host}): 'git' must be an object")
git_dict: dict[str, object] = typing.cast(dict[str, object], git_raw)
fetch_raw = git_dict.get("fetch", False)
if fetch_raw is True or fetch_raw is False:
git_fetch = fetch_raw
else:
raise ValueError(f"{label} ({host}): 'git.fetch' must be a boolean")
for k in git_dict:
if k != "fetch":
raise ValueError(
f"{label} ({host}): git has unknown key {k!r}; "
"accepted key is 'fetch'"
)
# dlp detectors
outbound_detectors, inbound_detectors, outbound_on_match = parse_inspect_block(
idx, host, settings,
)
preserve_auth_raw = settings.get("preserve_auth", False)
if preserve_auth_raw is not True and preserve_auth_raw is not False:
raise ValueError(
f"{label} ({host}): 'preserve_auth' must be a boolean"
)
preserve_auth: bool = preserve_auth_raw
for k in settings:
if k not in (
"matches", "auth_scheme", "token_env", "git", "preserve_auth",
"outbound_detectors", "inbound_detectors", "outbound_on_match",
):
raise ValueError(
f"{label} ({host}): inspect has unknown key {k!r}"
)
for k in raw_dict:
if not legacy_flat and k not in ("host", "inspect"):
raise ValueError(
f"{label} ({host}): unknown key {k!r}; accepted keys "
f"are 'host' and 'inspect'"
)
return Route(
host=host,
matches=matches,
auth_scheme=auth_scheme,
token_env=token_env,
git_fetch=git_fetch,
outbound_detectors=outbound_detectors,
inbound_detectors=inbound_detectors,
outbound_on_match=outbound_on_match,
preserve_auth=preserve_auth,
inspect=inspect,
)
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
d: dict[str, object] = {"value": pm.value}
if pm.type != "prefix":
d["type"] = pm.type
return d
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
d: dict[str, object] = {"name": hm.name, "value": hm.value}
if hm.type != "exact":
d["type"] = hm.type
return d
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
d: dict[str, object] = {}
if me.paths:
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
if me.methods:
d["methods"] = list(me.methods)
if me.headers:
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
return d
def route_to_yaml_dict(r: Route) -> dict[str, object]:
"""Serialize a Route to YAML-schema-compatible dict.
Uses the same field names the YAML parser accepts, so the output
can be round-tripped directly into an `allow` or `egress-block`
proposal without translation. Fields that are empty/default are
omitted so the agent doesn't copy irrelevant keys."""
d: dict[str, object] = {"host": r.host}
if not r.inspect:
d["inspect"] = False
return d
inspected: dict[str, object] = {}
if r.auth_scheme:
inspected["auth_scheme"] = r.auth_scheme
inspected["token_env"] = r.token_env
if r.matches:
inspected["matches"] = [_match_entry_to_dict(m) for m in r.matches]
if r.git_fetch:
inspected["git"] = {"fetch": True}
if r.outbound_detectors is not None:
inspected["outbound_detectors"] = list(r.outbound_detectors)
if r.inbound_detectors is not None:
inspected["inbound_detectors"] = list(r.inbound_detectors)
if r.outbound_on_match:
inspected["outbound_on_match"] = r.outbound_on_match
if r.preserve_auth:
inspected["preserve_auth"] = True
if inspected:
d["inspect"] = inspected
return d
def parse_config(payload: object) -> "Config":
"""Parse a full egress config payload (top-level log level + routes)."""
if not isinstance(payload, dict):
raise ValueError("routes payload: top-level must be an object")
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
log_raw: object = payload_dict.get("log", LOG_OFF)
if log_raw is True or log_raw is False or not isinstance(log_raw, int) \
or log_raw not in (LOG_OFF, LOG_BLOCKS, LOG_FULL):
raise ValueError(
f"routes payload: 'log' must be {LOG_OFF}, {LOG_BLOCKS}, or {LOG_FULL}"
)
routes = parse_routes(payload)
return Config(routes=routes, log=log_raw)
def load_config(text: str) -> "Config":
"""Parse YAML text → Config (routes + log flag)."""
try:
payload = parse_yaml_subset(text)
except YamlSubsetError as e:
raise ValueError(f"routes payload: invalid YAML: {e}") from e
return parse_config(payload)
-81
View File
@@ -1,81 +0,0 @@
"""Shared egress policy value objects.
Kept dependency-free so the schema parser, matcher, DLP scanner, and addon
adapter can use the same immutable public shapes without importing each other.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
PATH_MATCH_TYPES = ("exact", "prefix", "regex")
HEADER_MATCH_TYPES = ("exact", "regex")
VALID_METHODS = frozenset({
"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE",
"CONNECT",
})
LOG_OFF = 0
LOG_BLOCKS = 1
LOG_FULL = 2
@dataclass(frozen=True)
class PathMatch:
type: str
value: str
compiled: re.Pattern[str] | None = None
@dataclass(frozen=True)
class HeaderMatch:
name: str
value: str
type: str = "exact"
compiled: re.Pattern[str] | None = None
@dataclass(frozen=True)
class MatchEntry:
paths: tuple[PathMatch, ...] = ()
methods: tuple[str, ...] = ()
headers: tuple[HeaderMatch, ...] = ()
@dataclass(frozen=True)
class Route:
host: str
matches: tuple[MatchEntry, ...] = ()
auth_scheme: str = ""
token_env: str = ""
git_fetch: bool = False
outbound_detectors: tuple[str, ...] | None = None
inbound_detectors: tuple[str, ...] | None = None
outbound_on_match: str = ""
preserve_auth: bool = False
inspect: bool = True
@dataclass(frozen=True)
class Config:
routes: tuple[Route, ...]
log: int = LOG_OFF
deny_reason: str = ""
@dataclass(frozen=True)
class Decision:
action: str
reason: str = ""
inject_authorization: str | None = None
@dataclass(frozen=True)
class ScanResult:
severity: str
reason: str
location: str = ""
context: str = ""
matched: str = ""
-21
View File
@@ -252,27 +252,6 @@ cat > "$refs_file"
zero=0000000000000000000000000000000000000000
# Phase 0: reject Gitea AGit review refs before scanning or forwarding.
# A push to refs/for/*, refs/draft/*, or refs/for-review/* asks Gitea to
# open a pull request backed by a server-managed refs/pull/<n>/head rather
# than an ordinary refs/heads/* branch. That breaks the git-gate workflow:
# follow-up commits can't be pushed back through the branch, and Gitea
# rejects later direct updates to the generated review ref. Fail the whole
# push here (before any gitleaks scan or upstream forward) so the caller
# pushes a real branch and opens the PR against it instead. Deletions
# (new == zero) stay allowed so stale AGit refs can still be cleaned up.
while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue
case "$ref" in
refs/for/*|refs/draft/*|refs/for-review/*)
echo "git-gate: refusing AGit review ref $ref" >&2
echo "git-gate: push to refs/heads/<branch> and open a branch-backed pull request instead" >&2
exit 1
;;
esac
done < "$refs_file"
supervise_gitleaks_allow() {
log_opts=$1
ref=$2
+3 -3
View File
@@ -58,9 +58,9 @@ import typing
from dataclasses import dataclass
from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.gateway.egress.context import resolve_client_context
from bot_bottle.gateway.egress.schema import load_config, route_to_yaml_dict
from bot_bottle.gateway.egress.types import LOG_OFF
from bot_bottle.gateway.egress.addon_core import (
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
)
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.supervisor import types as _sv
+6 -33
View File
@@ -18,7 +18,6 @@ import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from ..log import debug
from ..orchestrator_auth import ROLE_CLI
from ..trust_domain import CONTROL_PLANE
from .server import ORCHESTRATOR_AUTH_HEADER
@@ -54,23 +53,6 @@ class RegisteredBottle:
env_var_secret: str = ""
@dataclass(frozen=True)
class BackendProbeFailure:
"""Safe diagnostic for an optional backend discovery probe."""
backend: str
error_type: str
def _probe_failure(backend: str, exc: BaseException) -> BackendProbeFailure:
failure = BackendProbeFailure(backend, type(exc).__name__)
debug(
"orchestrator discovery probe unavailable",
context={"backend": failure.backend, "error_type": failure.error_type},
)
return failure
class OrchestratorClient:
"""Trusted host-side client for the orchestrator control plane.
@@ -263,41 +245,32 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
orchestrator TAP. Returns the first that answers `/health`; raises if none
do (no orchestrator up launch a bottle first)."""
candidates: list[str] = []
failures: list[BackendProbeFailure] = []
try: # docker: loopback-published control plane
from .lifecycle import DEFAULT_PORT as _DOCKER_PORT
candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}")
except Exception as exc: # noqa: BLE001 — backend optional
failures.append(_probe_failure("docker", exc))
except Exception: # noqa: BLE001 — backend optional
candidates.append("http://127.0.0.1:8099")
try: # firecracker: infra VM control plane on the orchestrator TAP
from ..backend.firecracker import netpool
from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
candidates.append(
f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
except Exception as exc: # noqa: BLE001 — backend optional / not firecracker
failures.append(_probe_failure("firecracker", exc))
except Exception: # noqa: BLE001 — backend optional / not firecracker
pass
try: # macOS: orchestrator container on its host-only address
from ..backend.macos_container.infra import probe_orchestrator_url
url = probe_orchestrator_url()
if url:
candidates.append(url)
except Exception as exc: # noqa: BLE001 — backend optional / not macOS
failures.append(_probe_failure("macos-container", exc))
except Exception: # noqa: BLE001 — backend optional / not macOS
pass
for url in candidates:
if OrchestratorClient(url, timeout=timeout).health():
return url
detail = ""
if failures:
detail = "; optional probes unavailable: " + ", ".join(
f"{failure.backend} ({failure.error_type})" for failure in failures
)
raise OrchestratorClientError(
"no running orchestrator control plane found (tried "
+ ", ".join(candidates)
+ ")"
+ detail
+ "; launch a bottle first"
+ "); launch a bottle first"
)
+1 -9
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
from ..log import debug
from .client import OrchestratorClient, OrchestratorClientError
@@ -28,14 +27,7 @@ def reprovision_bottles(
try:
if client.reprovision_gateway(bottle_id, secret):
restored += 1
except OrchestratorClientError as exc:
debug(
"gateway secret reprovision failed; continuing with other bottles",
context={
"bottle_id": bottle_id,
"error_type": type(exc).__name__,
},
)
except OrchestratorClientError:
continue
return restored
+8 -20
View File
@@ -57,7 +57,6 @@ from __future__ import annotations
import http.server
import json
import math
import os
import socketserver
import sys
@@ -218,18 +217,13 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
raw_ips = data.get("live_source_ips")
if not isinstance(raw_ips, list):
return 400, {"error": "live_source_ips (list of strings) is required"}
if any(not isinstance(ip, str) or not ip for ip in raw_ips):
return 400, {"error": "live_source_ips must contain non-empty strings"}
live = raw_ips
live = [ip for ip in raw_ips if isinstance(ip, str) and ip]
grace = data.get("grace_seconds")
kwargs: dict[str, float] = {}
if grace is not None:
if isinstance(grace, bool) or not isinstance(grace, (int, float)):
return 400, {"error": "grace_seconds must be a non-negative finite number"}
parsed_grace = float(grace)
if not math.isfinite(parsed_grace) or parsed_grace < 0:
return 400, {"error": "grace_seconds must be a non-negative finite number"}
kwargs["grace_seconds"] = parsed_grace
kwargs = (
{"grace_seconds": float(grace)}
if isinstance(grace, (int, float)) and not isinstance(grace, bool)
else {}
)
return 200, {"reaped": orch.reconcile(live, **kwargs)}
if method == "POST" and route == "/attribute":
@@ -379,15 +373,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
status, payload = dispatch(
server.orchestrator, method, self.path, body, role=role)
except Exception as e: # noqa: BLE001 — the control plane must stay up
# Do not echo exception messages to the caller or logs: broker and
# persistence exceptions can contain request data. The operation,
# route, and exception type are enough to correlate a traceback.
sys.stderr.write(
f"orchestrator: {method} {self.path} failed "
f"[error_type={type(e).__name__}]\n"
)
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
sys.stderr.flush()
status, payload = 500, {"error": "internal error"}
status, payload = 500, {"error": f"internal error: {e}"}
data = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
+23 -47
View File
@@ -12,22 +12,12 @@ reattachment path reads ENV_VAR_SECRET from the running agent container via
``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
stored rows and re-populates ``_tokens``.
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode, **authenticated**
encrypt-then-MAC (stdlib-only, no external deps). Each value is encrypted
independently. The output blob is ``nonce (16 bytes) || ciphertext || tag
(32 bytes)`` encoded as URL-safe base64 (no padding).
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode (stdlib-only,
no external deps). Each value is encrypted independently. The output blob is
``nonce (16 bytes) || ciphertext`` encoded as URL-safe base64 (no padding).
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
mac_key = HMAC-SHA256(key, "bottled-secret-mac-v1")
tag = HMAC-SHA256(mac_key, nonce || ciphertext)
The tag is what makes a **wrong key deterministically detectable**: without it,
CTR decryption with the wrong key yields garbage that only fails when it isn't
valid UTF-8 (so ``reprovision`` would sometimes "succeed" with a wrong
ENV_VAR_SECRET and inject garbage egress tokens). The MAC key is derived from
the ENV_VAR_SECRET by a domain-separated HMAC so the same key never both
generates the keystream and signs the tag with the same message shape.
"""
from __future__ import annotations
@@ -39,7 +29,6 @@ import secrets
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call
_TAG_BYTES = 32 # HMAC-SHA256 authentication tag
_BLOCK = 32 # HMAC-SHA256 output width == one keystream block
# Env-var name the agent container receives at startup.
@@ -61,58 +50,45 @@ def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
).digest()
def _tag(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
"""The authentication tag over ``nonce || ciphertext``, keyed by a MAC
subkey domain-separated from the keystream key."""
mac_key = hmac.new(key, b"bottled-secret-mac-v1", hashlib.sha256).digest()
return hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
def _ctr(key: bytes, nonce: bytes, data: bytes) -> bytes:
"""CTR keystream XOR — its own inverse, so it both encrypts and decrypts."""
out = bytearray()
for i in range(0, len(data), _BLOCK):
chunk = data[i : i + _BLOCK]
ks = _keystream(key, nonce, i)[: len(chunk)]
out.extend(b ^ k for b, k in zip(chunk, ks))
return bytes(out)
def encrypt_value(secret_b64: str, plaintext: str) -> str:
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET).
Returns a URL-safe base64 blob ``nonce || ciphertext || tag`` suitable for
Returns a URL-safe base64 blob ``nonce || ciphertext`` suitable for
the ``bottled_agent_secrets.value`` column."""
key = _b64dec(secret_b64)
pt = plaintext.encode()
nonce = secrets.token_bytes(_NONCE_BYTES)
ct = _ctr(key, nonce, plaintext.encode())
tag = _tag(key, nonce, ct)
return base64.urlsafe_b64encode(nonce + ct + tag).rstrip(b"=").decode()
ct = bytearray()
for i in range(0, len(pt), _BLOCK):
chunk = pt[i : i + _BLOCK]
ks = _keystream(key, nonce, i)[: len(chunk)]
ct.extend(p ^ k for p, k in zip(chunk, ks))
return base64.urlsafe_b64encode(nonce + bytes(ct)).rstrip(b"=").decode()
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
"""Decrypt a blob produced by :func:`encrypt_value`.
Returns the original plaintext string. Raises ``ValueError`` for malformed
input, a **wrong key**, or a tampered ciphertext all caught by the
authentication tag before any plaintext is returned, so a wrong
ENV_VAR_SECRET is rejected deterministically (never a garbage token)."""
input or a key mismatch (wrong key produces garbage, not an error, unless
the plaintext is non-UTF-8 treat all such failures as wrong key)."""
key = _b64dec(secret_b64)
try:
blob = _b64dec(blob_b64)
except Exception as exc:
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
if len(blob) < _NONCE_BYTES + _TAG_BYTES:
if len(blob) < _NONCE_BYTES:
raise ValueError("ciphertext blob too short")
nonce = blob[:_NONCE_BYTES]
tag = blob[-_TAG_BYTES:]
ciphertext = blob[_NONCE_BYTES:-_TAG_BYTES]
if not hmac.compare_digest(tag, _tag(key, nonce, ciphertext)):
raise ValueError("ciphertext failed authentication (wrong key or tampered)")
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
pt = bytearray()
for i in range(0, len(ciphertext), _BLOCK):
chunk = ciphertext[i : i + _BLOCK]
ks = _keystream(key, nonce, i)[: len(chunk)]
pt.extend(c ^ k for c, k in zip(chunk, ks))
try:
return _ctr(key, nonce, ciphertext).decode()
except UnicodeDecodeError as exc: # pragma: no cover - authenticated, so unreachable
raise ValueError(f"decryption produced non-UTF-8 output: {exc}") from exc
return bytes(pt).decode()
except UnicodeDecodeError as exc:
raise ValueError(f"decryption produced non-UTF-8 output (wrong key?): {exc}") from exc
__all__ = ["ENV_VAR_SECRET_NAME", "new_env_var_secret", "encrypt_value", "decrypt_value"]
-73
View File
@@ -20,9 +20,7 @@ from __future__ import annotations
import fcntl
import hashlib
import json
import os
import re
import shutil
import tempfile
from pathlib import Path
@@ -39,11 +37,9 @@ _BUNDLED = _PKG / "_resources" # wheel-shipped copies
# the two lists in sync (``test_resources`` guards that every entry exists).
BUNDLED_RESOURCES: tuple[str, ...] = (
"pyproject.toml",
"image-build-args.json",
"Dockerfile.gateway",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
"requirements.gateway.lock",
"nix/firecracker-netpool.nix",
"scripts/firecracker-netpool.sh",
)
@@ -51,12 +47,6 @@ BUNDLED_RESOURCES: tuple[str, ...] = (
# Present at a checkout root, never in a bare installed package — the cheap
# tell for which layout we're in.
_CHECKOUT_MARKER = "Dockerfile.gateway"
_IMAGE_BUILD_ARGS_FILE = "image-build-args.json"
_CENTRAL_BUILD_ARG_NAMES = frozenset({
"DOCKER_CLI_BASE_IMAGE",
"NODE_BASE_IMAGE",
"PYTHON_BASE_IMAGE",
})
class ResourceError(RuntimeError):
@@ -88,69 +78,6 @@ def dockerfile(name: str) -> Path:
return build_root() / name
def image_build_args(
dockerfile_path: str | Path,
*,
context: str | Path | None = None,
) -> dict[str, str]:
"""Return centralized arguments declared by ``dockerfile_path``.
Base-image arguments deliberately have no Dockerfile defaults. Their
digest-pinned values live in one repository input file and every supported
build path calls this helper before invoking its OCI builder. Explicit
caller-supplied arguments may still override this returned mapping.
"""
path = Path(dockerfile_path)
if not path.is_absolute() and context is not None:
path = Path(context) / path
try:
text = path.read_text(encoding="utf-8")
except OSError:
# Generic callers and tests may build an ephemeral Dockerfile outside
# bot-bottle. The builder will report a genuinely missing file.
return {}
declared = set(re.findall(
r"(?m)^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)\s*$",
text,
))
wanted = declared & _CENTRAL_BUILD_ARG_NAMES
if not wanted:
return {}
# Generated Dockerfiles (notably the macOS nested-container layer) live in
# temporary build contexts. Resolve their declaration there, but take the
# centralized values from that context only when it carries its own input
# file; otherwise use bot-bottle's staged build root.
context_root = Path(context) if context is not None else None
root = (
context_root
if context_root is not None
and (context_root / _IMAGE_BUILD_ARGS_FILE).is_file()
else build_root()
)
inputs_path = root / _IMAGE_BUILD_ARGS_FILE
try:
inputs = json.loads(inputs_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ResourceError(
f"cannot read centralized image build arguments from {inputs_path}: {exc}"
) from exc
if not isinstance(inputs, dict):
raise ResourceError(f"{inputs_path} must contain a JSON object")
missing = wanted - inputs.keys()
if missing:
raise ResourceError(
f"{inputs_path} lacks required image build arguments: "
f"{', '.join(sorted(missing))}"
)
invalid = [name for name in wanted if not isinstance(inputs[name], str)]
if invalid:
raise ResourceError(
f"{inputs_path} has non-string image build arguments: "
f"{', '.join(sorted(invalid))}"
)
return {name: inputs[name] for name in sorted(wanted)}
def nix_netpool_module() -> Path:
"""Absolute path to the firecracker netpool NixOS module."""
return build_root() / "nix" / "firecracker-netpool.nix"
-20
View File
@@ -9,11 +9,8 @@ import difflib
import hashlib
import ipaddress
import os
import re
import sys
from .log import die
def sha256_hex(content: str) -> str:
"""Hex SHA-256 of a UTF-8 string."""
@@ -70,20 +67,3 @@ def expand_tilde(path: str) -> str:
home = os.environ.get("HOME", "")
return home + path[1:]
return path
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def slugify(name: str) -> str:
"""Return a portable bottle identifier from a human-readable name.
This is deliberately a root utility: names are part of the generic CLI
and state model, not a Docker container concern.
"""
if not name:
die("slugify: missing name")
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
if not slug:
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
return slug
+42 -45
View File
@@ -1,53 +1,50 @@
# CI
## Required pull-request gate
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
It runs the unit suite plus one integration job per backend
(`integration-docker`, `integration-firecracker`, `integration-macos`) on:
[`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml) runs the unit
suite, Docker integration suite, combined coverage report, and diff-coverage
gate when tested package/build inputs change on a pull request or on `main`.
- every push to a branch with an open pull request, and
- every push to `main`.
The Docker job preflights the backend before discovery. Gitea's `act_runner`
runs the job in a container with the host Docker socket, so the test process
reaches control-plane siblings through the job's Docker network and uses named
Docker volumes for orchestrator/CA state the host daemon must mount. The
orchestrator runs the package baked into the image built from the checkout; it
does not bind the job container's invisible workspace into a sibling container.
Docker integration jobs share fixed singleton names, so required and manual
runs use one non-cancelling concurrency group. The shared agent/gateway network
has an explicit subnet, which Docker requires for the pinned source IPs used as
the isolation/attribution key.
`integration-macos` is the exception: it is **advisory**, running only on
`workflow_dispatch` (manual dispatch), never on push or pull requests. It targets the
Apple Container backend on a self-hosted macOS runner (label `macos`,
registered in host mode — Apple Container can't run in a Linux container, so it
can't reuse the `kvm` runner). A single non-redundant laptop must not be able
to block a PR merge, so the job stays out of the `coverage` job's `needs` and
its coverage never feeds the diff-coverage gate. Because the infra container is
a singleton (`bot-bottle-mac-infra`), the job declares a `concurrency` group
and tears the container down on exit; keep runner concurrency at 1. See the
README "macOS Apple Container" CI note for runner provisioning.
`scripts.unittest_gate` enforces the Docker job's contract: all 22 integration
tests must execute and none may skip. This includes the real gateway-image,
control-plane authentication, multitenant policy/token isolation,
sandbox-escape, and orphan-network tests. Backend skip decorators remain useful
for local runs, but the CI preflight plus execution-count gate prevents a
missing backend or runner-topology regression from becoming a green job.
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
runs a **preflight** (`./cli.py backend status --backend=<name>`) that
prints a clear per-check readiness summary and fails the job when the
backend is missing — so absent infrastructure is visible at the job level
rather than hidden among per-test `unittest.skip` lines. The skip guards in
[`tests/_backend.py`](../tests/_backend.py) gate on the same readiness
check (`bot_bottle.backend.has_backend`): backend-agnostic tests use
`skip_unless_selected_backend_available()` and run through whichever
backend is selected (checking, e.g., Linux + `/dev/kvm` for Firecracker
rather than unrelated Docker availability); Docker-implementation tests use
`skip_unless_backend("docker")` and no-op under a non-Docker run.
Combined unit + Docker coverage is informational globally. Two focused gates
are enforced:
A small subset of integration tests skip when running specifically
under Gitea Actions (`GITEA_ACTIONS=true`), because `act_runner` runs
the job inside a container with the host's `/var/run/docker.sock`
mounted in. That topology breaks two assumptions those tests make:
- changed executable Python lines must be at least 90% covered; and
- the validated critical security/logic core must remain at least 90% covered.
- networks created via the host daemon aren't always visible to a
same-process `docker network ls` call from inside the job container,
and
- ports published by sibling containers land on the host's loopback,
not on the job container's `127.0.0.1` — so HTTP probes against
`http://127.0.0.1:<host_port>` from inside the job time out.
## Privileged pre-release matrix
[`.gitea/workflows/pre-release-test.yml`](../.gitea/workflows/pre-release-test.yml)
is manually dispatched before a release. It repeats unit and Docker integration
coverage, then runs:
- Firecracker integration on the self-hosted `kvm` runner; and
- advisory Apple Container integration on the self-hosted `macos` runner.
These privileged host-mode runners never execute unreviewed pull-request code
automatically. Firecracker coverage is combined in the manual pre-release
report; macOS reports advisory coverage in its own job. The macOS infra
container is a singleton, so its job uses a concurrency group and always tears
the service down.
## Scheduled canary
[`.gitea/workflows/canaries.yml`](../.gitea/workflows/canaries.yml) runs weekly
and on manual dispatch. It verifies the pinned gitleaks release URL, checksum,
archive shape, and executable. The same unittest execution gate requires at
least one executed canary and rejects skips.
The affected tests (`test_orphan_cleanup.test_create_and_remove`,
`test_gateway_image.TestGatewayImage`) still run
locally where the test process and Docker daemon share a host.
Making them work in CI is a follow-up: either re-write them to
discover container IPs via `docker inspect`, or reconfigure the
runner with host networking.
+6 -10
View File
@@ -34,13 +34,12 @@ a regression (Goodhart's law).
Coverage is **risk-weighted**, measured over the **combined unit +
integration** suites, with three rules:
1. **Critical modules must remain ≥ 90%.** The curated security/logic core
covers the host and gateway egress policy, manifest trust boundary,
git-gate enforcement, supervise protocol/server, YAML parser, and bottle
state. The concrete module list lives in `scripts/critical-modules.txt`;
`scripts/critical_modules.py` rejects stale or ambiguous entries before
Coverage.py can silently ignore them. These modules are unit-testable, so
CI enforces the aggregate minimum independently of diff coverage.
1. **Critical modules target ≥ 90%.** The security/logic core
`egress_addon{,_core}.py`, `dlp_detectors.py`, `egress.py`,
`manifest*.py`, `git_gate.py`, `git_http_backend.py`, `supervise.py`,
`yaml_subset.py`, `bottle_state.py` — is Docker-independent and
unit-testable, so it carries the high bar. We ratchet toward 90% as
these modules are touched; new gaps in them are not acceptable.
2. **Subprocess/backend orchestration is covered by the integration
suite, not omitted.** `scripts/coverage.sh` runs unit + integration
@@ -83,9 +82,6 @@ omit list.
(critical-module standard + diff coverage) are Docker-independent.
- "We're at N%" is now a curated figure; outsiders should read the
policy, not just the badge.
- A rename or removal in the curated list fails CI. Updating the list is an
explicit review of where the security-critical behavior moved, not a way to
improve the percentage by omission.
## Links
-68
View File
@@ -1,68 +0,0 @@
# Container image build inputs
Bot-bottle's supported images are intended to rebuild from the same declared
inputs on Linux amd64 and arm64. The repository enforces four layers of
immutability:
- Python, Node, and nested-container Docker CLI base images are required,
defaultless Docker build arguments.
Their version-qualified tags and multi-platform OCI index digests live
together in `image-build-args.json`; every supported builder reads that
file and passes only the arguments its Dockerfile declares.
- Debian packages resolve from the same dated `snapshot.debian.org` archive in
every image that runs `apt-get install`. The snapshot endpoint uses HTTP so a
fresh image does not need a host-specific TLS interception CA; APT still
authenticates the repository metadata and package hashes with Debian's
signed Release files.
- Gateway Python dependencies install from `requirements.gateway.lock` with
`pip --require-hashes`; provider npm packages install with `npm ci` from
committed lockfiles.
- The Codex standalone archive is fetched from the exact `CODEX_VERSION`
release and checked against the committed upstream
`codex-package_SHA256SUMS` before extraction.
The standalone layout is retained because Codex remote control requires it.
`Dockerfile.orchestrator.fc` is the one intentionally dynamic `FROM`. It has no
default value: the Firecracker build coordinator resolves the freshly built
orchestrator's local `sha256:` image ID, creates a local tag containing the
complete ID, verifies that tag resolves to the same ID, and supplies the
content-derived reference as `ORCHESTRATOR_BASE_IMAGE`. This accommodates
BuildKit, which treats a bare image ID in `FROM` as a registry repository.
## Refreshing inputs
Make refreshes on a feature branch and review them like an application
dependency update:
1. For a Python or Node base update, select a version-qualified tag and update
its one digest-pinned value in `image-build-args.json`. Confirm the index
still contains `linux/amd64` and `linux/arm64`.
2. For Debian packages, advance `DEBIAN_SNAPSHOT` to one fixed UTC timestamp in
every Dockerfile that uses apt. Do not use the moving Debian mirrors.
3. Change direct Python versions in `requirements.gateway.in`, direct npm
versions in the provider's `package.json`, or `CODEX_VERSION` in the Codex
Dockerfile. Direct versions must be exact—no ranges, dist-tags, or
unversioned package names.
4. Push the feature branch or manually run the `refresh-image-locks` workflow.
It regenerates the four derived files with the image's exact Python and Node
versions and uploads them as the `image-input-locks` artifact. It never
writes to the branch, so a refresh cannot race with developer pushes.
5. Download the artifact, replace the four committed files, review the lock
diff, and let both normal CI and `image-input-builds` pass.
The latter checks both base architectures, builds every supported image,
exercises the exact local Firecracker base-ID handoff, and runs each
provider CLI's version smoke test.
Run the fast policy check locally at any point:
```sh
python3 scripts/check_image_inputs.py
```
The check rejects mutable or `latest` bases, moving apt repositories,
network-to-shell installers, unlocked npm/Pi installs, non-exact direct
dependencies, missing npm integrity values, and unhashed gateway Python
requirements.
Image signing and attestation are tracked separately in issue #339; this policy
covers deterministic and integrity-checked build inputs.
-5
View File
@@ -1,5 +0,0 @@
{
"DOCKER_CLI_BASE_IMAGE": "docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d",
"NODE_BASE_IMAGE": "node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba",
"PYTHON_BASE_IMAGE": "python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de"
}
-5
View File
@@ -38,13 +38,8 @@ include = ["bot_bottle*"]
bot_bottle = [
"gateway/egress/entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/claude/package.json",
"contrib/claude/package-lock.json",
"contrib/codex/Dockerfile",
"contrib/codex/codex-package_SHA256SUMS",
"contrib/pi/Dockerfile",
"contrib/pi/package.json",
"contrib/pi/package-lock.json",
"backend/firecracker/netpool.defaults.env",
"backend/macos_container/nested-containers-init.sh",
]
-5
View File
@@ -1,5 +0,0 @@
# Runtime-only third-party dependency for Dockerfile.gateway.
#
# Regenerate requirements.gateway.lock with the image-input refresh workflow;
# Dockerfile.gateway installs the lock with --require-hashes.
mitmproxy==11.1.3
-837
View File
@@ -1,837 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --generate-hashes --output-file=requirements.gateway.lock requirements.gateway.in
#
aioquic==1.2.0 \
--hash=sha256:1de513772fd04ff38028fdf748a9e2dec33d7aa2fbf67fda3011d9a85b620c54 \
--hash=sha256:2466499759b31ea4f1d17f4aeb1f8d4297169e05e3c1216d618c9757f4dd740d \
--hash=sha256:358e2b9c1e0c24b9933094c3c2cf990faf44d03b64d6f8ff79b4b3f510c6c268 \
--hash=sha256:3976b75e82d83742c8b811e38d273eda2ca7f81394b6a85da33a02849c5f1d9d \
--hash=sha256:3e23964dfb04526ade6e66f5b7cd0c830421b8138303ab60ba6e204015e7cb0b \
--hash=sha256:43ae3b11d43400a620ca0b4b4885d12b76a599c2cbddba755f74bebfa65fe587 \
--hash=sha256:6371c3afa1036294e1505fdbda8c147bc41c5b6709a47459e8c1b4eec41a86ef \
--hash=sha256:6e418c92898a0af306e6f1b6a55a0d3d2597001c57a7b1ba36cf5b47bf41233b \
--hash=sha256:6fe683943ea3439dd0aca05ff80e85a552d4b39f9f34858c76ac54c205990e88 \
--hash=sha256:7dcc212bb529900757d8e99a76198b42c2a978ce735a1bfca394033c16cfc33c \
--hash=sha256:81650d59bef05c514af2cfdcb2946e9d13367b745e68b36881d43630ef563d38 \
--hash=sha256:84d733332927b76218a3b246216104116f766f5a9b2308ec306cd017b3049660 \
--hash=sha256:8e600da7aa7e4a7bc53ee8f45fd66808032127ae00938c119ac77d66633b8961 \
--hash=sha256:910d8c91da86bba003d491d15deaeac3087d1b9d690b9edc1375905d8867b742 \
--hash=sha256:bb917143e7a4de5beba1e695fa89f2b05ef080b450dea07338cc67a9c75e0a4d \
--hash=sha256:c22689c33fe4799624aed6faaba0af9e6ea7d31ac45047745828ee68d67fe1d9 \
--hash=sha256:c332cffa3c2124e5db82b2b9eb2662bd7c39ee2247606b74de689f6d3091b61a \
--hash=sha256:cbe7167b2faee887e115d83d25332c4b8fa4604d5175807d978cb4fe39b4e36e \
--hash=sha256:cd75015462ca5070a888110dc201f35a9f4c7459f9201b77adc3c06013611bb8 \
--hash=sha256:e2c3c127cc3d9eac7a6d05142036bf4b2c593d750a115a2fa42c1f86cbe8c0a0 \
--hash=sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a \
--hash=sha256:e7ce10198f8efa91986ad8ac83fa08e50972e0aacde45bdaf7b9365094e72c0c \
--hash=sha256:f209ad5edbff8239e994c189dc74428420957448a190f4343faee4caedef4eee \
--hash=sha256:f81e7946f09501a7c27e3f71b84a455e6bf92346fb5a28ef2d73c9d564463c63 \
--hash=sha256:f91263bb3f71948c5c8915b4d50ee370004f20a416f67fab3dcc90556c7e7199 \
--hash=sha256:fcc1eb083ed9f8d903482e375281c8c26a5ed2b6bee5ee2be3f13275d8fdb146
# via mitmproxy
argon2-cffi==23.1.0 \
--hash=sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08 \
--hash=sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea
# via mitmproxy
argon2-cffi-bindings==25.1.0 \
--hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \
--hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \
--hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \
--hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \
--hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \
--hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \
--hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \
--hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \
--hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \
--hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \
--hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \
--hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \
--hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \
--hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \
--hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \
--hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \
--hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \
--hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \
--hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \
--hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \
--hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \
--hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \
--hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \
--hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \
--hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \
--hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb
# via argon2-cffi
asgiref==3.8.1 \
--hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
--hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
# via mitmproxy
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
# via service-identity
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
brotli==1.1.0 \
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
--hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \
--hash=sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419 \
--hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
--hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \
--hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
--hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
--hash=sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757 \
--hash=sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2 \
--hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \
--hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \
--hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \
--hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \
--hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \
--hash=sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0 \
--hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \
--hash=sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943 \
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
--hash=sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28 \
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
--hash=sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f \
--hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \
--hash=sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547 \
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
--hash=sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0 \
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
--hash=sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a \
--hash=sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb \
--hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
--hash=sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2 \
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
--hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \
--hash=sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec \
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
--hash=sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c \
--hash=sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38 \
--hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
--hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \
--hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \
--hash=sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368 \
--hash=sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c \
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
--hash=sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f \
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
--hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \
--hash=sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8 \
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
--hash=sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c \
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
--hash=sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7 \
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
--hash=sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9 \
--hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \
--hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \
--hash=sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5 \
--hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \
--hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \
--hash=sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b \
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
--hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \
--hash=sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648 \
--hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \
--hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \
--hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
--hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \
--hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \
--hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \
--hash=sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2 \
--hash=sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0 \
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
--hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \
--hash=sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75 \
--hash=sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5 \
--hash=sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f \
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
--hash=sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f \
--hash=sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb \
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
--hash=sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111 \
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
--hash=sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01 \
--hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \
--hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
--hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \
--hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \
--hash=sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7 \
--hash=sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c \
--hash=sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284 \
--hash=sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52 \
--hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \
--hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
--hash=sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1 \
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
--hash=sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839 \
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
--hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \
--hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \
--hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \
--hash=sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089 \
--hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \
--hash=sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b \
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \
--hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \
--hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \
--hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \
--hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064
# via mitmproxy
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# aioquic
# mitmproxy
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via
# argon2-cffi-bindings
# cryptography
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via flask
cryptography==44.0.3 \
--hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \
--hash=sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43 \
--hash=sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645 \
--hash=sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8 \
--hash=sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44 \
--hash=sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d \
--hash=sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f \
--hash=sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d \
--hash=sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54 \
--hash=sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9 \
--hash=sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137 \
--hash=sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f \
--hash=sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c \
--hash=sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334 \
--hash=sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c \
--hash=sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b \
--hash=sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2 \
--hash=sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375 \
--hash=sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88 \
--hash=sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5 \
--hash=sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647 \
--hash=sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c \
--hash=sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359 \
--hash=sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5 \
--hash=sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d \
--hash=sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028 \
--hash=sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01 \
--hash=sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904 \
--hash=sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d \
--hash=sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93 \
--hash=sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06 \
--hash=sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff \
--hash=sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76 \
--hash=sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff \
--hash=sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759 \
--hash=sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4 \
--hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053
# via
# aioquic
# mitmproxy
# pyopenssl
# service-identity
flask==3.1.0 \
--hash=sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac \
--hash=sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136
# via mitmproxy
h11==0.14.0 \
--hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \
--hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
# via
# mitmproxy
# wsproto
h2==4.1.0 \
--hash=sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d \
--hash=sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb
# via mitmproxy
hpack==4.2.0 \
--hash=sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0 \
--hash=sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986
# via h2
hyperframe==6.1.0 \
--hash=sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5 \
--hash=sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08
# via
# h2
# mitmproxy
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
kaitaistruct==0.10 \
--hash=sha256:a044dee29173d6afbacf27bcac39daf89b654dd418cfa009ab82d9178a9ae52a \
--hash=sha256:a97350919adbf37fda881f75e9365e2fb88d04832b7a4e57106ec70119efb235
# via mitmproxy
ldap3==2.9.1 \
--hash=sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70 \
--hash=sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f
# via mitmproxy
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# jinja2
# werkzeug
mitmproxy==11.1.3 \
--hash=sha256:2305880b46465d1a9bdcdac369655826f588d05f382b082249a3e532a0e52952
# via -r requirements.gateway.in
mitmproxy-linux==0.11.5 \
--hash=sha256:00a40d08a1522d5718e9ff87458a950f06f62e5374d154d851122c0eb41c5dc0 \
--hash=sha256:544be1db84575fc8ecc71fb566032cabe4a65a4891d5bd0dc688e3023b49a18a \
--hash=sha256:d7ce0b91d7a510009d532e6abbebe59f027a011fa745b13faa5b4d9ebe92abf5 \
--hash=sha256:e6a31faf244a9e3d44db02e3e3301aa2e699da67188820982a93028884f4cba8 \
--hash=sha256:ee3782fe4e7ccc6a899fa0ef5ad3e35a3ec358587304bd4d212188d2462c8f82
# via mitmproxy-rs
mitmproxy-rs==0.11.5 \
--hash=sha256:05f0da03165c2ee2803f91e6648bc9409692f42d796cbaf3fec5a20754ca8c39 \
--hash=sha256:2f668dc92573cc3c3ba8c75b166276d846ce7321daf37f4a68bd837538298c5c \
--hash=sha256:5353ad0c828aaa37ac53511f3960e39c0888848565f5faa3ea09e205ed8a7350 \
--hash=sha256:5a4ffe6d20b3a0edb47b40cd60e7b62709c29e8adf2573514cc0abd1442acf63 \
--hash=sha256:971241cb70bad87b55f12bc6e8d7dd3efd02a1acbe1730703e2cfeeb6edd3908
# via mitmproxy
msgpack==1.1.0 \
--hash=sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b \
--hash=sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf \
--hash=sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca \
--hash=sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330 \
--hash=sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f \
--hash=sha256:13599f8829cfbe0158f6456374e9eea9f44eee08076291771d8ae93eda56607f \
--hash=sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39 \
--hash=sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247 \
--hash=sha256:3180065ec2abbe13a4ad37688b61b99d7f9e012a535b930e0e683ad6bc30155b \
--hash=sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c \
--hash=sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7 \
--hash=sha256:3df7e6b05571b3814361e8464f9304c42d2196808e0119f55d0d3e62cd5ea044 \
--hash=sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6 \
--hash=sha256:42f754515e0f683f9c79210a5d1cad631ec3d06cea5172214d2176a42e67e19b \
--hash=sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0 \
--hash=sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2 \
--hash=sha256:46c34e99110762a76e3911fc923222472c9d681f1094096ac4102c18319e6468 \
--hash=sha256:471e27a5787a2e3f974ba023f9e265a8c7cfd373632247deb225617e3100a3c7 \
--hash=sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734 \
--hash=sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434 \
--hash=sha256:4d1b7ff2d6146e16e8bd665ac726a89c74163ef8cd39fa8c1087d4e52d3a2325 \
--hash=sha256:53258eeb7a80fc46f62fd59c876957a2d0e15e6449a9e71842b6d24419d88ca1 \
--hash=sha256:534480ee5690ab3cbed89d4c8971a5c631b69a8c0883ecfea96c19118510c846 \
--hash=sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88 \
--hash=sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420 \
--hash=sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e \
--hash=sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2 \
--hash=sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59 \
--hash=sha256:646afc8102935a388ffc3914b336d22d1c2d6209c773f3eb5dd4d6d3b6f8c1cb \
--hash=sha256:64fc9068d701233effd61b19efb1485587560b66fe57b3e50d29c5d78e7fef68 \
--hash=sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915 \
--hash=sha256:685ec345eefc757a7c8af44a3032734a739f8c45d1b0ac45efc5d8977aa4720f \
--hash=sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701 \
--hash=sha256:73322a6cc57fcee3c0c57c4463d828e9428275fb85a27aa2aa1a92fdc42afd7b \
--hash=sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d \
--hash=sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa \
--hash=sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d \
--hash=sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd \
--hash=sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc \
--hash=sha256:7e7b853bbc44fb03fbdba34feb4bd414322180135e2cb5164f20ce1c9795ee48 \
--hash=sha256:879a7b7b0ad82481c52d3c7eb99bf6f0645dbdec5134a4bddbd16f3506947feb \
--hash=sha256:8a706d1e74dd3dea05cb54580d9bd8b2880e9264856ce5068027eed09680aa74 \
--hash=sha256:8a84efb768fb968381e525eeeb3d92857e4985aacc39f3c47ffd00eb4509315b \
--hash=sha256:8cf9e8c3a2153934a23ac160cc4cba0ec035f6867c8013cc6077a79823370346 \
--hash=sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e \
--hash=sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6 \
--hash=sha256:914571a2a5b4e7606997e169f64ce53a8b1e06f2cf2c3a7273aa106236d43dd5 \
--hash=sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f \
--hash=sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5 \
--hash=sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b \
--hash=sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c \
--hash=sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f \
--hash=sha256:c40ffa9a15d74e05ba1fe2681ea33b9caffd886675412612d93ab17b58ea2fec \
--hash=sha256:c5a91481a3cc573ac8c0d9aace09345d989dc4a0202b7fcb312c88c26d4e71a8 \
--hash=sha256:c921af52214dcbb75e6bdf6a661b23c3e6417f00c603dd2070bccb5c3ef499f5 \
--hash=sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d \
--hash=sha256:d8ce0b22b890be5d252de90d0e0d119f363012027cf256185fc3d474c44b1b9e \
--hash=sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e \
--hash=sha256:e0856a2b7e8dcb874be44fea031d22e5b3a19121be92a1e098f46068a11b0870 \
--hash=sha256:e1f3c3d21f7cf67bcf2da8e494d30a75e4cf60041d98b3f79875afb5b96f3a3f \
--hash=sha256:f1ba6136e650898082d9d5a5217d5906d1e138024f836ff48691784bbe1adf96 \
--hash=sha256:f3e9b4936df53b970513eac1758f3882c88658a220b58dcc1e39606dccaaf01c \
--hash=sha256:f80bc7d47f76089633763f952e67f8214cb7b3ee6bfa489b3cb6a84cfac114cd \
--hash=sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788
# via mitmproxy
passlib==1.7.4 \
--hash=sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1 \
--hash=sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04
# via mitmproxy
publicsuffix2==2.20191221 \
--hash=sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b \
--hash=sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893
# via mitmproxy
pyasn1==0.6.4 \
--hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \
--hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b
# via
# ldap3
# pyasn1-modules
# service-identity
pyasn1-modules==0.4.2 \
--hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
--hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
# via service-identity
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pylsqpack==0.3.24 \
--hash=sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9 \
--hash=sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9 \
--hash=sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c \
--hash=sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767 \
--hash=sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890 \
--hash=sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d \
--hash=sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a \
--hash=sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff \
--hash=sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b \
--hash=sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0 \
--hash=sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c \
--hash=sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc
# via aioquic
pyopenssl==25.0.0 \
--hash=sha256:424c247065e46e76a37411b9ab1782541c23bb658bf003772c3405fbaa128e90 \
--hash=sha256:cd2cef799efa3936bb08e8ccb9433a575722b9dd986023f1cabc4ae64e9dac16
# via
# aioquic
# mitmproxy
pyparsing==3.2.1 \
--hash=sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1 \
--hash=sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a
# via mitmproxy
pyperclip==1.9.0 \
--hash=sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310
# via mitmproxy
ruamel-yaml==0.18.10 \
--hash=sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58 \
--hash=sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1
# via mitmproxy
ruamel-yaml-clib==0.2.15 \
--hash=sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490 \
--hash=sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14 \
--hash=sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6 \
--hash=sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a \
--hash=sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9 \
--hash=sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d \
--hash=sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5 \
--hash=sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3 \
--hash=sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe \
--hash=sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c \
--hash=sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc \
--hash=sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf \
--hash=sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900 \
--hash=sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a \
--hash=sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa \
--hash=sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6 \
--hash=sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd \
--hash=sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25 \
--hash=sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600 \
--hash=sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf \
--hash=sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642 \
--hash=sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614 \
--hash=sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf \
--hash=sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000 \
--hash=sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb \
--hash=sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690 \
--hash=sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e \
--hash=sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137 \
--hash=sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d \
--hash=sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401 \
--hash=sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f \
--hash=sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2 \
--hash=sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471 \
--hash=sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed \
--hash=sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524 \
--hash=sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60 \
--hash=sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef \
--hash=sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043 \
--hash=sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03 \
--hash=sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815 \
--hash=sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77 \
--hash=sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4 \
--hash=sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d \
--hash=sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467 \
--hash=sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7 \
--hash=sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e \
--hash=sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec \
--hash=sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4 \
--hash=sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd \
--hash=sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff \
--hash=sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c \
--hash=sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862 \
--hash=sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974 \
--hash=sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922 \
--hash=sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a \
--hash=sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d \
--hash=sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262 \
--hash=sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144 \
--hash=sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1 \
--hash=sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51 \
--hash=sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f
# via ruamel-yaml
service-identity==24.2.0 \
--hash=sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85 \
--hash=sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09
# via aioquic
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via mitmproxy
tornado==6.4.2 \
--hash=sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803 \
--hash=sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec \
--hash=sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482 \
--hash=sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634 \
--hash=sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38 \
--hash=sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b \
--hash=sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c \
--hash=sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf \
--hash=sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946 \
--hash=sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73 \
--hash=sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1
# via mitmproxy
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# pyopenssl
# urwid
urwid==2.6.16 \
--hash=sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2 \
--hash=sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797
# via mitmproxy
wcwidth==0.8.2 \
--hash=sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda \
--hash=sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85
# via urwid
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via flask
wsproto==1.2.0 \
--hash=sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065 \
--hash=sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736
# via mitmproxy
zstandard==0.23.0 \
--hash=sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473 \
--hash=sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916 \
--hash=sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15 \
--hash=sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072 \
--hash=sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4 \
--hash=sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e \
--hash=sha256:1bfe8de1da6d104f15a60d4a8a768288f66aa953bbe00d027398b93fb9680b26 \
--hash=sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8 \
--hash=sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5 \
--hash=sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd \
--hash=sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c \
--hash=sha256:29a2bc7c1b09b0af938b7a8343174b987ae021705acabcbae560166567f5a8db \
--hash=sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5 \
--hash=sha256:2ef3775758346d9ac6214123887d25c7061c92afe1f2b354f9388e9e4d48acfc \
--hash=sha256:2f146f50723defec2975fb7e388ae3a024eb7151542d1599527ec2aa9cacb152 \
--hash=sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269 \
--hash=sha256:32ba3b5ccde2d581b1e6aa952c836a6291e8435d788f656fe5976445865ae045 \
--hash=sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e \
--hash=sha256:379b378ae694ba78cef921581ebd420c938936a153ded602c4fea612b7eaa90d \
--hash=sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a \
--hash=sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb \
--hash=sha256:4051e406288b8cdbb993798b9a45c59a4896b6ecee2f875424ec10276a895740 \
--hash=sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105 \
--hash=sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274 \
--hash=sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2 \
--hash=sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58 \
--hash=sha256:50a80baba0285386f97ea36239855f6020ce452456605f262b2d33ac35c7770b \
--hash=sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4 \
--hash=sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db \
--hash=sha256:53ea7cdc96c6eb56e76bb06894bcfb5dfa93b7adcf59d61c6b92674e24e2dd5e \
--hash=sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9 \
--hash=sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0 \
--hash=sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813 \
--hash=sha256:61062387ad820c654b6a6b5f0b94484fa19515e0c5116faf29f41a6bc91ded6e \
--hash=sha256:61f89436cbfede4bc4e91b4397eaa3e2108ebe96d05e93d6ccc95ab5714be512 \
--hash=sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0 \
--hash=sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b \
--hash=sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48 \
--hash=sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a \
--hash=sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772 \
--hash=sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed \
--hash=sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373 \
--hash=sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea \
--hash=sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd \
--hash=sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f \
--hash=sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc \
--hash=sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23 \
--hash=sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2 \
--hash=sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db \
--hash=sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70 \
--hash=sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259 \
--hash=sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9 \
--hash=sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700 \
--hash=sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003 \
--hash=sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba \
--hash=sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a \
--hash=sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c \
--hash=sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90 \
--hash=sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690 \
--hash=sha256:a05e6d6218461eb1b4771d973728f0133b2a4613a6779995df557f70794fd60f \
--hash=sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840 \
--hash=sha256:a4ae99c57668ca1e78597d8b06d5af837f377f340f4cce993b551b2d7731778d \
--hash=sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9 \
--hash=sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35 \
--hash=sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd \
--hash=sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a \
--hash=sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea \
--hash=sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1 \
--hash=sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573 \
--hash=sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09 \
--hash=sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094 \
--hash=sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78 \
--hash=sha256:b8c0bd73aeac689beacd4e7667d48c299f61b959475cdbb91e7d3d88d27c56b9 \
--hash=sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5 \
--hash=sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9 \
--hash=sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391 \
--hash=sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847 \
--hash=sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2 \
--hash=sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c \
--hash=sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2 \
--hash=sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057 \
--hash=sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20 \
--hash=sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d \
--hash=sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4 \
--hash=sha256:e2d1a054f8f0a191004675755448d12be47fa9bebbcffa3cdf01db19f2d30a54 \
--hash=sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171 \
--hash=sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e \
--hash=sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160 \
--hash=sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b \
--hash=sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58 \
--hash=sha256:f83fa6cae3fff8e98691248c9320356971b59678a17f20656a9e59cd32cee6d8 \
--hash=sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33 \
--hash=sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a \
--hash=sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880 \
--hash=sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca \
--hash=sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b \
--hash=sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69
# via mitmproxy
-330
View File
@@ -1,330 +0,0 @@
#!/usr/bin/env python3
"""Fail when a supported image reintroduces mutable build inputs."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DOCKERFILES = (
Path("Dockerfile.gateway"),
Path("Dockerfile.orchestrator"),
Path("Dockerfile.orchestrator.fc"),
Path("bot_bottle/contrib/claude/Dockerfile"),
Path("bot_bottle/contrib/codex/Dockerfile"),
Path("bot_bottle/contrib/pi/Dockerfile"),
)
NPM_MANIFESTS = (
Path("bot_bottle/contrib/claude/package.json"),
Path("bot_bottle/contrib/pi/package.json"),
)
IMAGE_BUILD_ARGS = Path("image-build-args.json")
CODEX_CHECKSUMS = Path("bot_bottle/contrib/codex/codex-package_SHA256SUMS")
REQUIRED_CODEX_ASSETS = frozenset({
"codex-package-aarch64-unknown-linux-musl.tar.gz",
"codex-package-x86_64-unknown-linux-musl.tar.gz",
})
REQUIRED_BASE_ARGS = frozenset({
"DOCKER_CLI_BASE_IMAGE",
"NODE_BASE_IMAGE",
"PYTHON_BASE_IMAGE",
})
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
_EXACT_NPM_VERSION = re.compile(
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$",
)
_SNAPSHOT = re.compile(r"ARG\s+DEBIAN_SNAPSHOT=\d{8}T\d{6}Z")
_NETWORK_TO_SHELL = re.compile(
r"(?:curl|wget)\b[^|\n]*\|\s*(?:ba)?sh\b",
re.IGNORECASE,
)
def _logical_lines(text: str) -> str:
"""Join Dockerfile continuations so policy regexes see one instruction."""
return re.sub(r"\\\r?\n", " ", text)
def check_dockerfile(
path: Path,
text: str,
image_build_args: dict[str, str] | None = None,
) -> list[str]:
"""Return policy violations for one Dockerfile."""
problems: list[str] = []
logical = _logical_lines(text)
instructions = "\n".join(
line for line in logical.splitlines()
if not line.lstrip().startswith("#")
)
from_matches = list(re.finditer(r"(?im)^\s*FROM\s+(\S+)", logical))
from_values = [match.group(1) for match in from_matches]
preamble = logical[:from_matches[0].start()] if from_matches else logical
base_args = {
match.group(1): match.group(2)
for match in re.finditer(
r"(?im)^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)(?:=(\S+))?\s*$",
preamble,
)
}
if not from_values:
problems.append(f"{path}: missing FROM")
for value in from_values:
variable = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", value)
if variable and variable.group(1) == "ORCHESTRATOR_BASE_IMAGE":
if (
"ORCHESTRATOR_BASE_IMAGE" not in base_args
or base_args["ORCHESTRATOR_BASE_IMAGE"] is not None
):
problems.append(
f"{path}: local base argument must have no mutable default",
)
continue
if variable:
name = variable.group(1)
if name not in base_args:
problems.append(
f"{path}: dynamic base argument {name} is not declared before FROM",
)
continue
if base_args[name] is not None:
problems.append(
f"{path}: base argument {name} must not define a default",
)
continue
if image_build_args is None or name not in image_build_args:
problems.append(
f"{path}: base argument {name} lacks a centralized value",
)
continue
value = image_build_args[name]
if "$" in value:
problems.append(f"{path}: dynamic base image is not content-pinned: {value}")
continue
try:
image, digest = value.rsplit("@sha256:", 1)
except ValueError:
problems.append(f"{path}: base image lacks a sha256 digest: {value}")
continue
leaf = image.rsplit("/", 1)[-1]
if ":" not in leaf:
problems.append(f"{path}: base image lacks a version-qualified tag: {value}")
elif leaf.rsplit(":", 1)[1] == "latest":
problems.append(f"{path}: base image uses latest: {value}")
if not _DIGEST.fullmatch(digest):
problems.append(f"{path}: base image digest is not 64 lowercase hex: {value}")
if _NETWORK_TO_SHELL.search(instructions):
problems.append(f"{path}: downloads network content directly into a shell")
if re.search(r"\b(?:npm\s+(?:i|install)|pi\s+install)\b", instructions):
problems.append(f"{path}: use npm ci with a committed integrity lock")
if "apt-get install" in instructions:
if not _SNAPSHOT.search(text):
problems.append(f"{path}: apt install lacks a fixed Debian snapshot")
if "snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" not in text:
problems.append(f"{path}: apt sources are not routed to the snapshot")
if "Acquire::Check-Valid-Until=false" not in text:
problems.append(f"{path}: snapshot apt update lacks validity override")
if path == Path("Dockerfile.gateway"):
if "--require-hashes" not in instructions:
problems.append(f"{path}: gateway Python install does not require hashes")
if "requirements.gateway.lock" not in text:
problems.append(f"{path}: gateway Python lock is not consumed")
if path == Path("bot_bottle/contrib/codex/Dockerfile"):
required = (
"codex-package_SHA256SUMS",
"sha256sum -c",
"codex-package-${codex_target}.tar.gz",
)
for marker in required:
if marker not in instructions:
problems.append(f"{path}: verified pinned Codex install lacks {marker!r}")
return problems
def load_image_build_args(root: Path) -> tuple[dict[str, str], list[str]]:
"""Load and validate the one repository location for base-image args."""
path = root / IMAGE_BUILD_ARGS
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {}, [f"{IMAGE_BUILD_ARGS}: cannot read image build arguments: {exc}"]
if not isinstance(data, dict):
return {}, [f"{IMAGE_BUILD_ARGS}: must contain a JSON object"]
problems: list[str] = []
names = set(data)
missing = REQUIRED_BASE_ARGS - names
unexpected = names - REQUIRED_BASE_ARGS
if missing:
problems.append(
f"{IMAGE_BUILD_ARGS}: missing arguments: {', '.join(sorted(missing))}",
)
if unexpected:
problems.append(
f"{IMAGE_BUILD_ARGS}: unexpected arguments: "
f"{', '.join(sorted(unexpected))}",
)
for name, value in data.items():
if not isinstance(value, str):
problems.append(f"{IMAGE_BUILD_ARGS}: {name} must be a string")
return {
name: value for name, value in data.items()
if isinstance(name, str) and isinstance(value, str)
}, problems
def check_npm_manifest(root: Path, manifest_path: Path) -> list[str]:
"""Validate exact direct versions and integrity-complete npm locks."""
problems: list[str] = []
manifest_file = root / manifest_path
lock_file = manifest_file.with_name("package-lock.json")
try:
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
lock = json.loads(lock_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return [f"{manifest_path}: cannot read manifest and lock: {exc}"]
direct = manifest.get("dependencies", {})
for package, version in direct.items():
if not isinstance(version, str) or not _EXACT_NPM_VERSION.fullmatch(version):
problems.append(
f"{manifest_path}: {package} is not an exact version: {version!r}",
)
root_lock = lock.get("packages", {}).get("", {}).get("dependencies", {})
if root_lock != direct:
problems.append(f"{lock_file.relative_to(root)}: root dependencies are stale")
if lock.get("lockfileVersion") != 3:
problems.append(f"{lock_file.relative_to(root)}: require lockfileVersion 3")
for package_path, package in lock.get("packages", {}).items():
if not package_path or package.get("link"):
continue
resolved = package.get("resolved", "")
if isinstance(resolved, str) and resolved.startswith(("http://", "https://")):
integrity = package.get("integrity", "")
if not isinstance(integrity, str) or not integrity.startswith("sha512-"):
problems.append(
f"{lock_file.relative_to(root)}: "
f"{package_path} lacks sha512 integrity",
)
return problems
def check_python_lock(root: Path) -> list[str]:
"""Validate that every gateway Python requirement is exact and hashed."""
path = root / "requirements.gateway.lock"
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
return [f"requirements.gateway.lock: cannot read lock: {exc}"]
problems: list[str] = []
blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", text)
requirements = [block for block in blocks if re.match(r"^[A-Za-z0-9_.-]+==", block)]
if not requirements:
problems.append("requirements.gateway.lock: contains no exact requirements")
for block in requirements:
name = block.split("==", 1)[0]
first_line = block.splitlines()[0]
if not re.match(r"^[A-Za-z0-9_.-]+==[^\s\\]+", first_line):
problems.append(f"requirements.gateway.lock: {name} is not exact")
if not re.search(r"--hash=sha256:[0-9a-f]{64}", block):
problems.append(f"requirements.gateway.lock: {name} lacks a sha256 hash")
try:
direct = [
line.strip()
for line in (root / "requirements.gateway.in").read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
except OSError as exc:
problems.append(f"requirements.gateway.in: cannot read input: {exc}")
direct = []
for requirement in direct:
if not re.fullmatch(r"[A-Za-z0-9_.-]+==[^\s]+", requirement):
problems.append(
f"requirements.gateway.in: direct dependency is not exact: {requirement}",
)
if not re.search(rf"(?m)^{re.escape(requirement)}(?:\s|\\)", text):
problems.append(
f"requirements.gateway.lock: missing direct dependency {requirement}",
)
return problems
def check_codex_checksums(root: Path) -> list[str]:
"""Require one valid digest for every supported standalone archive."""
try:
lines = (root / CODEX_CHECKSUMS).read_text(encoding="utf-8").splitlines()
except OSError as exc:
return [f"{CODEX_CHECKSUMS}: cannot read checksum manifest: {exc}"]
assets: dict[str, str] = {}
problems: list[str] = []
for line in lines:
match = re.fullmatch(r"([0-9a-f]{64}) (\S+)", line)
if match is None:
problems.append(f"{CODEX_CHECKSUMS}: malformed checksum line: {line!r}")
continue
digest, asset = match.groups()
if asset in assets:
problems.append(f"{CODEX_CHECKSUMS}: duplicate checksum for {asset}")
assets[asset] = digest
missing = REQUIRED_CODEX_ASSETS - assets.keys()
if missing:
problems.append(
f"{CODEX_CHECKSUMS}: missing supported archives: "
f"{', '.join(sorted(missing))}",
)
return problems
def check_repo(root: Path = REPO_ROOT) -> list[str]:
"""Return every repository image-input policy violation."""
image_build_args, problems = load_image_build_args(root)
for path in DOCKERFILES:
try:
text = (root / path).read_text(encoding="utf-8")
except OSError as exc:
problems.append(f"{path}: cannot read Dockerfile: {exc}")
continue
problems.extend(check_dockerfile(path, text, image_build_args))
for manifest in NPM_MANIFESTS:
problems.extend(check_npm_manifest(root, manifest))
problems.extend(check_codex_checksums(root))
nested_path = Path(
"bot_bottle/backend/macos_container/nested_containers.py",
)
try:
nested_source = (root / nested_path).read_text(encoding="utf-8")
except OSError as exc:
problems.append(f"{nested_path}: cannot read generated image source: {exc}")
else:
if "FROM docker:" in nested_source:
problems.append(
"nested-containers image uses a mutable Docker CLI base",
)
if '"ARG DOCKER_CLI_BASE_IMAGE\\n"' not in nested_source:
problems.append(
"nested-containers image does not consume DOCKER_CLI_BASE_IMAGE",
)
if 'image = f"{pinned_base}{IMAGE_SUFFIX}"' not in nested_source:
problems.append(
"nested-containers output is not keyed by its pinned agent base",
)
problems.extend(check_python_lock(root))
return problems
def main() -> int:
problems = check_repo()
if problems:
for problem in problems:
print(f"image-input policy: {problem}", file=sys.stderr)
return 1
print("image-input policy: all supported image inputs are pinned and verified")
return 0
if __name__ == "__main__":
sys.exit(main())
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Complete duplicate npm lock entries from an identical verified artifact."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def complete_lock(path: Path) -> int:
"""Fill missing SRI only from the same resolved URL; return change count."""
data = json.loads(path.read_text(encoding="utf-8"))
packages = data.get("packages", {})
integrity_by_url: dict[str, str] = {}
for package in packages.values():
resolved = package.get("resolved")
integrity = package.get("integrity")
if not isinstance(resolved, str) or not resolved.startswith(
("http://", "https://"),
):
continue
if not isinstance(integrity, str) or not integrity.startswith("sha512-"):
continue
prior = integrity_by_url.setdefault(resolved, integrity)
if prior != integrity:
raise ValueError(f"conflicting integrity values for {resolved}")
changed = 0
missing: list[str] = []
for package_path, package in packages.items():
resolved = package.get("resolved")
if not isinstance(resolved, str) or not resolved.startswith(
("http://", "https://"),
):
continue
integrity = package.get("integrity")
if isinstance(integrity, str) and integrity.startswith("sha512-"):
continue
known = integrity_by_url.get(resolved)
if known is None:
missing.append(package_path)
continue
package["integrity"] = known
changed += 1
if missing:
joined = ", ".join(missing)
raise ValueError(f"no verified duplicate artifact for: {joined}")
if changed:
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return changed
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("locks", nargs="+", type=Path)
args = parser.parse_args()
for path in args.locks:
changed = complete_lock(path)
print(f"{path}: completed {changed} duplicate integrity entries")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8 -8
View File
@@ -20,10 +20,10 @@ cd "$(dirname "$0")/.."
PY="${PYTHON:-python3}"
# Critical security/logic core held to the high bar by ADR 0004. The helper
# fails before coverage when a curated path was renamed or removed; Coverage.py
# itself would silently ignore that stale include and inflate the score.
CRITICAL=$("$PY" scripts/critical_modules.py)
# Critical security/logic core held to the high bar by ADR 0004. The list
# lives in one place (scripts/critical-modules.txt) so this report and the
# README "core coverage" badge can't drift; comma-join it for --include.
CRITICAL=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
if [ "${1:-}" = "aggregate" ]; then
# Aggregate mode: combine .coverage.* artifacts already in the workspace.
@@ -34,8 +34,8 @@ if [ "${1:-}" = "aggregate" ]; then
"$PY" -m coverage report -m
if [ "${2:-}" = "critical" ]; then
echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" --fail-under=90
echo "== critical modules (ADR 0004 target: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL"
fi
exit 0
fi
@@ -55,6 +55,6 @@ echo "== combined report ==" >&2
"$PY" -m coverage report -m
if [ "${1:-}" = "critical" ]; then
echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" --fail-under=90
echo "== critical modules (ADR 0004 target: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL"
fi
+9 -38
View File
@@ -7,48 +7,19 @@
# number that silently stops measuring a module is worse than no badge.
#
# One module path per line, relative to the repo root. Blank lines and
# `#` comments are ignored. scripts/critical_modules.py rejects missing,
# duplicate, non-Python, and out-of-repository entries before coverage runs.
# Host-side egress planning and secret preparation.
bot_bottle/egress/plan.py
bot_bottle/egress/service.py
# Gateway egress policy, matching, and DLP enforcement.
# `#` comments are ignored.
bot_bottle/gateway/egress/addon.py
bot_bottle/gateway/egress/addon_core.py
bot_bottle/gateway/egress/context.py
bot_bottle/gateway/egress/dlp.py
bot_bottle/gateway/egress/dlp_config.py
bot_bottle/gateway/egress/dlp_detectors.py
bot_bottle/gateway/egress/matching.py
bot_bottle/gateway/egress/schema.py
bot_bottle/gateway/egress/types.py
# Manifest trust boundary and schema.
bot_bottle/manifest/agent.py
bot_bottle/manifest/bottle.py
bot_bottle/manifest/egress.py
bot_bottle/manifest/extends.py
bot_bottle/manifest/git.py
bot_bottle/manifest/index.py
bot_bottle/manifest/loader.py
bot_bottle/manifest/schema.py
bot_bottle/manifest/util.py
# Host-side and gateway-side git policy enforcement.
bot_bottle/git_gate/host_key.py
bot_bottle/git_gate/plan.py
bot_bottle/git_gate/provision.py
bot_bottle/git_gate/service.py
bot_bottle/egress.py
bot_bottle/manifest.py
bot_bottle/manifest_egress.py
bot_bottle/manifest_agent.py
bot_bottle/manifest_schema.py
bot_bottle/git_gate.py
bot_bottle/gateway/git_gate/render.py
bot_bottle/git_gate_provision.py
bot_bottle/gateway/git_gate/http_backend.py
# Supervise proposal protocol and data plane.
bot_bottle/supervisor/plan.py
bot_bottle/supervisor/types.py
bot_bottle/gateway/supervisor/server.py
# Shared parsers and state validation.
bot_bottle/supervise.py
bot_bottle/yaml_subset.py
bot_bottle/bottle_state.py
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""Validate and render the critical-module coverage manifest.
Coverage.py silently ignores an ``--include`` path that does not exist. That
is useful for broad globs, but dangerous for bot-bottle's curated security
core: a rename could otherwise improve the reported percentage by removing a
module from the measurement. Keep the validation in one small stdlib helper
and make every coverage consumer call it.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "critical-modules.txt"
class CriticalModulesError(ValueError):
"""The critical-module manifest is empty, ambiguous, or stale."""
def load_critical_modules(manifest: Path, *, root: Path) -> list[str]:
"""Return validated module paths relative to *root*.
Entries must be unique, concrete Python files inside the repository.
Globs are deliberately rejected by the file check: each rename must update
this explicit security review surface.
"""
root = root.resolve()
try:
lines = manifest.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise CriticalModulesError(
f"cannot read critical-module manifest {manifest}: {exc}"
) from exc
modules: list[str] = []
seen: set[str] = set()
errors: list[str] = []
for line_number, raw in enumerate(lines, start=1):
entry = raw.strip()
if not entry or entry.startswith("#"):
continue
path = Path(entry)
prefix = f"{manifest}:{line_number}: {entry!r}"
if path.is_absolute():
errors.append(f"{prefix} must be relative to the repository root")
continue
try:
resolved = (root / path).resolve()
resolved.relative_to(root)
except ValueError:
errors.append(f"{prefix} escapes the repository root")
continue
if entry in seen:
errors.append(f"{prefix} is duplicated")
continue
seen.add(entry)
if path.suffix != ".py":
errors.append(f"{prefix} is not a Python module")
continue
if not resolved.is_file():
errors.append(f"{prefix} does not exist")
continue
modules.append(path.as_posix())
if not modules and not errors:
errors.append(f"{manifest}: contains no critical modules")
if errors:
raise CriticalModulesError("\n".join(errors))
return modules
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="validate and print the critical coverage include list"
)
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--root", type=Path, default=REPO_ROOT)
parser.add_argument(
"--check", action="store_true",
help="validate only; do not print the comma-separated include list",
)
args = parser.parse_args(argv)
try:
modules = load_critical_modules(args.manifest, root=args.root)
except CriticalModulesError as exc:
print(f"critical-modules: {exc}", file=sys.stderr)
return 1
if not args.check:
print(",".join(modules))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -8
View File
@@ -35,12 +35,5 @@ chmod 600 "$fake_key_dir/fake-key"
# Build the image graph quietly so the recorded run shows only the
# bottle launch and the four `!` probes, not BuildKit progress.
node_base_image=$(
python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])'
)
docker build -q \
--build-arg "NODE_BASE_IMAGE=$node_base_image" \
-f bot_bottle/contrib/claude/Dockerfile \
-t bot-bottle-claude:latest . >/dev/null 2>&1 || true
docker build -q -f bot_bottle/contrib/claude/Dockerfile -t bot-bottle-claude:latest . >/dev/null 2>&1 || true
docker build -q -f Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""Run unittest discovery with explicit execution-count assurances.
The standard unittest CLI exits successfully when a suite contains skipped
tests. That is normally useful, but it let the Docker integration job stay
green while its security-boundary classes were all skipped under act_runner.
This wrapper keeps normal unittest output and adds opt-in minimum-executed and
no-skip gates for jobs that promise a concrete integration surface.
"""
from __future__ import annotations
import argparse
import sys
import unittest
def assurance_errors(
*, tests_run: int, skipped: int, minimum_executed: int, fail_on_skip: bool
) -> list[str]:
"""Return human-readable assurance failures for a completed suite."""
executed = tests_run - skipped
errors: list[str] = []
if executed < minimum_executed:
errors.append(
f"executed {executed} test(s), below required minimum "
f"{minimum_executed} (discovered {tests_run}, skipped {skipped})"
)
if fail_on_skip and skipped:
errors.append(f"{skipped} test(s) skipped in a no-skip suite")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="unittest discovery with execution-count assurance"
)
parser.add_argument("-s", "--start-directory", default=".")
parser.add_argument("-t", "--top-level-directory", default=None)
parser.add_argument("-p", "--pattern", default="test*.py")
parser.add_argument("--minimum-executed", type=int, default=0)
parser.add_argument("--fail-on-skip", action="store_true")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
suite = unittest.defaultTestLoader.discover(
args.start_directory,
pattern=args.pattern,
top_level_dir=args.top_level_directory,
)
result = unittest.TextTestRunner(
verbosity=2 if args.verbose else 1,
).run(suite)
failures = assurance_errors(
tests_run=result.testsRun,
skipped=len(result.skipped),
minimum_executed=args.minimum_executed,
fail_on_skip=args.fail_on_skip,
)
for failure in failures:
print(f"unittest-gate: {failure}", file=sys.stderr)
return 0 if result.wasSuccessful() and not failures else 1
if __name__ == "__main__":
raise SystemExit(main())
-2
View File
@@ -21,11 +21,9 @@ _ROOT = Path(__file__).resolve().parent
# Must match bot_bottle.resources.BUNDLED_RESOURCES (paths relative to root).
_BUNDLED_RESOURCES = (
"pyproject.toml",
"image-build-args.json",
"Dockerfile.gateway",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
"requirements.gateway.lock",
"nix/firecracker-netpool.nix",
"scripts/firecracker-netpool.sh",
)
+8 -12
View File
@@ -20,11 +20,10 @@ tests/
... # many others; see unit/ directory
integration/
test_gateway_image.py
test_sandbox_escape.py
test_dry_run_plan.py
test_orphan_cleanup.py
...
canaries/
test_gitleaks_release.py # opt-in upstream artifact check
canaries/ # opt-in; see below (currently empty)
```
Classification falls out of the directory — no hand-maintained list to
@@ -44,27 +43,24 @@ Discovery is invoked with `-t .` (top-level dir = repo root) so the
## What the integration tests cover
- `test_dry_run_plan.py``cli.py start --dry-run --format=json` emits
a structured plan that contains the resolved egress allowlist and
the bottle's runtime, and creates zero Docker resources.
- `test_orphan_cleanup.py``network_remove` is idempotent against
missing resources, so the EXIT trap can call it unconditionally.
- `test_gateway_image.py` — builds Dockerfile.gateway and
probes that gitleaks / mitmdump / supervise are all reachable
inside the gateway image.
- `test_orchestrator_docker_auth.py` — drives the real control-plane
container and verifies role-scoped authentication.
- `test_multitenant_isolation.py` and `test_sandbox_escape.py` — exercise
token/allowlist separation and end-to-end escape attempts.
## Canaries
`tests/canaries/` holds upstream-regression checks gated on
`BOT_BOTTLE_RUN_CANARIES=1` and not part of the per-push suite.
They're invoked by the scheduled `canaries` workflow. The gitleaks canary
downloads the exact release archive pinned by `Dockerfile.gateway`, verifies
its architecture-specific checksum, and executes the binary.
They're invoked by the scheduled `canaries` workflow. Currently
no canaries are defined.
```bash
BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
-t . -s tests/canaries -v --minimum-executed 1 --fail-on-skip
BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v
```
## What's NOT covered
-85
View File
@@ -1,85 +0,0 @@
"""Canary: the pinned gitleaks release remains downloadable and executable.
The gateway Dockerfile verifies this archive during an image build. Repeating
the upstream check weekly keeps registry/release drift out of normal pull
requests while proving that the pinned URL, architecture checksum, archive
shape, and binary still agree.
"""
from __future__ import annotations
import hashlib
import os
import platform
import re
import subprocess
import tarfile
import tempfile
import unittest
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DOCKERFILE = ROOT / "Dockerfile.gateway"
def _docker_arg(text: str, name: str) -> str:
match = re.search(rf"^ARG {re.escape(name)}=(\S+)$", text, re.MULTILINE)
if match is None:
raise AssertionError(f"Dockerfile.gateway has no concrete ARG {name}")
return match.group(1)
@unittest.skipUnless(
os.environ.get("BOT_BOTTLE_RUN_CANARIES") == "1",
"canary suite is opt-in; set BOT_BOTTLE_RUN_CANARIES=1 to run",
)
class TestGitleaksRelease(unittest.TestCase):
def test_pinned_archive_checksum_and_binary(self) -> None:
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
version = _docker_arg(dockerfile, "GITLEAKS_VERSION")
machine = platform.machine().lower()
architectures = {
"x86_64": ("linux_x64", "GITLEAKS_SHA256_AMD64"),
"amd64": ("linux_x64", "GITLEAKS_SHA256_AMD64"),
"aarch64": ("linux_arm64", "GITLEAKS_SHA256_ARM64"),
"arm64": ("linux_arm64", "GITLEAKS_SHA256_ARM64"),
}
if machine not in architectures:
self.fail(f"unsupported canary runner architecture: {machine}")
asset, checksum_arg = architectures[machine]
expected_checksum = _docker_arg(dockerfile, checksum_arg)
url = (
"https://github.com/gitleaks/gitleaks/releases/download/"
f"v{version}/gitleaks_{version}_{asset}.tar.gz"
)
with tempfile.TemporaryDirectory(prefix="bot-bottle-gitleaks-canary.") as tmp:
archive = Path(tmp) / "gitleaks.tar.gz"
urllib.request.urlretrieve(url, archive)
self.assertEqual(
expected_checksum,
hashlib.sha256(archive.read_bytes()).hexdigest(),
"the pinned upstream archive no longer matches Dockerfile.gateway",
)
with tarfile.open(archive, "r:gz") as bundle:
member = bundle.getmember("gitleaks")
source = bundle.extractfile(member)
if source is None:
self.fail("gitleaks archive member is not a regular file")
binary = Path(tmp) / "gitleaks"
binary.write_bytes(source.read())
binary.chmod(0o755)
result = subprocess.run(
[str(binary), "version"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn(version, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+14 -22
View File
@@ -14,7 +14,9 @@ the chunk-1 contract:
expected "no daemons selected" line when the supervisor is
pointed at an empty daemon set.
Skips cleanly only when the selected Docker backend is unavailable.
Skips cleanly when docker is unavailable, or under act_runner
where the host bind-mount topology breaks multi-stage builds
that pull large bases.
"""
from __future__ import annotations
@@ -23,7 +25,6 @@ import os
import subprocess
import unittest
from bot_bottle import resources
from tests._backend import skip_unless_backend
@@ -32,6 +33,12 @@ _DOCKERFILE = "Dockerfile.gateway"
@skip_unless_backend("docker")
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: multi-stage build pulls a 200+MB "
"mitmproxy base + two upstream gateway images; runner storage "
"+ time budget make this an interactive-only test",
)
class TestGatewayImage(unittest.TestCase):
"""Builds the image once for the class, then runs a few
`docker run` probes against it."""
@@ -39,25 +46,15 @@ class TestGatewayImage(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
build_args = resources.image_build_args(
_DOCKERFILE,
context=repo_root,
)
arg_flags = [
item
for name, value in build_args.items()
for item in ("--build-arg", f"{name}={value}")
]
proc = subprocess.run(
["docker", "build", "-t", _IMAGE,
"-f", _DOCKERFILE, *arg_flags, "."],
"-f", _DOCKERFILE, "."],
cwd=repo_root,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
check=False,
)
if proc.returncode != 0:
raise AssertionError(
f"docker build failed; image probes cannot run.\n"
raise unittest.SkipTest(
f"docker build failed; skipping image probes.\n"
f"{proc.stdout.decode('utf-8', errors='replace')[-2000:]}"
)
@@ -66,16 +63,14 @@ class TestGatewayImage(unittest.TestCase):
subprocess.run(
["docker", "image", "rm", "-f", _IMAGE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
def _run_in_image(self, *cmd: str, timeout: float = 30.0) -> tuple[int, str]:
proc = subprocess.run(
["docker", "run", "--rm", "--entrypoint", cmd[0], _IMAGE,
*cmd[1:]],
*cmd[1:]],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
)
return proc.returncode, proc.stdout.decode("utf-8", errors="replace")
@@ -96,9 +91,7 @@ class TestGatewayImage(unittest.TestCase):
# Probe that the package imports resolve inside the image.
rc, out = self._run_in_image(
"python3", "-c",
"from bot_bottle.supervisor import types; "
"from bot_bottle.gateway.supervisor import server as supervise_server; "
"print('ok')",
"from bot_bottle.supervisor import types; from bot_bottle.gateway.supervisor import server as supervise_server; print('ok')",
)
self.assertEqual(0, rc, msg=out)
self.assertIn("ok", out)
@@ -113,7 +106,6 @@ class TestGatewayImage(unittest.TestCase):
_IMAGE],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=10.0,
check=False,
)
out = proc.stdout.decode("utf-8", errors="replace")
self.assertEqual(0, proc.returncode, msg=out)
+34 -53
View File
@@ -16,10 +16,11 @@ throwaway BOT_BOTTLE_ROOT for a clean registry and tears everything down.
from __future__ import annotations
import secrets
import os
import subprocess
import time
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend.docker.consolidated_launch import (
_network_cidr,
@@ -72,12 +73,19 @@ _PROBE_SRC = (
@skip_unless_backend("docker")
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: the orchestrator container bind-mounts the repo "
"path into a container on the socket-shared host daemon, which can't see the "
"runner's /workspace — same host-bind-mount constraint as the other "
"bottle-bringup integration tests",
)
class TestMultitenantIsolation(unittest.TestCase):
def setUp(self) -> None:
# Named volume → a clean registry DB that is also visible to a
# socket-shared host daemon when the test process runs in act_runner.
self._root_volume = "bot-bottle-mtitest-root-" + secrets.token_hex(4)
self.svc = DockerInfraService(root_mount_source=self._root_volume)
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
# Throwaway root → a clean registry DB, independent of the host's.
self.svc = DockerInfraService(host_root=Path(self._tmp.name))
self.addCleanup(self._teardown_docker)
# ensure_running builds the bundle image (slow on a cold cache) and
# brings up the shared network + gateway + orchestrator.
@@ -92,8 +100,13 @@ class TestMultitenantIsolation(unittest.TestCase):
self.svc.stop()
subprocess.run(["docker", "network", "rm", GATEWAY_NETWORK],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
# The orchestrator container wrote the registry DB as root into the
# throwaway root; chown it back so the (non-root) tempdir cleanup can
# remove it.
subprocess.run(
["docker", "volume", "rm", "--force", self._root_volume],
["docker", "run", "--rm", "-v", f"{self._tmp.name}:/r",
"--entrypoint", "chown", GATEWAY_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
@staticmethod
@@ -119,61 +132,29 @@ class TestMultitenantIsolation(unittest.TestCase):
taken = _network_container_ips(GATEWAY_NETWORK) + extra_taken
return next_free_ip(_network_cidr(GATEWAY_NETWORK), taken)
def _probe(self, source_ip: str, identity_token: str, host: str) -> str:
deadline = time.monotonic() + 30
last = subprocess.CompletedProcess([], 1, "", "probe not attempted")
while time.monotonic() < deadline:
last = subprocess.run(
[
"docker", "run", "--rm",
"--network", GATEWAY_NETWORK, "--ip", source_ip,
"--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC,
f"http://bottle:{identity_token}@{self.gw_ip}:{EGRESS_PORT}",
host,
],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
check=False, timeout=90,
)
output = last.stdout.strip()
if last.returncode == 0 and output:
return output
time.sleep(0.25)
self.fail(
f"gateway probe did not become ready: "
f"exit={last.returncode}, stderr={last.stderr.strip()!r}"
def _probe(self, source_ip: str, host: str) -> str:
proc = subprocess.run(
["docker", "run", "--rm", "--network", GATEWAY_NETWORK, "--ip", source_ip,
"--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC,
f"http://{self.gw_ip}:{EGRESS_PORT}", host],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, timeout=90,
)
return proc.stdout.strip()
def test_two_bottles_share_gateway_with_isolated_tokens_and_allowlists(self) -> None:
ip_a = self._free_ip([])
ip_b = self._free_ip([ip_a])
bottle_a = self.client.register_bottle(
ip_a, policy=_POLICY_A, tokens={"EGRESS_TOKEN_0": _TOKEN_A}
)
bottle_b = self.client.register_bottle(
ip_b, policy=_POLICY_B, tokens={"EGRESS_TOKEN_0": _TOKEN_B}
)
self.client.register_bottle(ip_a, policy=_POLICY_A, tokens={"EGRESS_TOKEN_0": _TOKEN_A})
self.client.register_bottle(ip_b, policy=_POLICY_B, tokens={"EGRESS_TOKEN_0": _TOKEN_B})
# Each bottle gets its OWN token injected on the shared route — no bleed.
self.assertEqual(
f"200 AUTH=Bearer {_TOKEN_A}",
self._probe(ip_a, bottle_a.identity_token, "echo-shared"),
)
self.assertEqual(
f"200 AUTH=Bearer {_TOKEN_B}",
self._probe(ip_b, bottle_b.identity_token, "echo-shared"),
)
self.assertEqual(f"200 AUTH=Bearer {_TOKEN_A}", self._probe(ip_a, "echo-shared"))
self.assertEqual(f"200 AUTH=Bearer {_TOKEN_B}", self._probe(ip_b, "echo-shared"))
# Allowlist is per-bottle: echo-bonly is only in B's policy.
self.assertTrue(
self._probe(
ip_a, bottle_a.identity_token, "echo-bonly"
).startswith("403"), # fail-closed for A
"A reached a host outside its allowlist",
)
self.assertEqual(
"200 AUTH=NONE",
self._probe(ip_b, bottle_b.identity_token, "echo-bonly"),
) # allowed, unauthed for B
self.assertTrue(self._probe(ip_a, "echo-bonly").startswith("403"), # fail-closed for A
"A reached a host outside its allowlist")
self.assertEqual("200 AUTH=NONE", self._probe(ip_b, "echo-bonly")) # allowed, unauthed for B
if __name__ == "__main__":
@@ -23,6 +23,7 @@ import secrets
import subprocess
import tempfile
import unittest
from pathlib import Path
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
from bot_bottle.orchestrator.client import OrchestratorClient
@@ -37,6 +38,13 @@ _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
@skip_unless_backend("docker")
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: the orchestrator container bind-mounts the repo "
"path into a container on the socket-shared host daemon, which can't see the "
"runner's /workspace — same host-bind-mount constraint as the other "
"bottle-bringup integration tests",
)
class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@@ -66,10 +74,10 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
gateway_name = f"bot-bottle-gateway-itest-{suffix}"
network = f"bot-bottle-net-itest-{suffix}"
control_network = f"bot-bottle-ctrl-itest-{suffix}"
root_volume = f"bot-bottle-root-itest-{suffix}"
host_root = Path(cls._tmp.name)
cls.addClassCleanup(
cls._teardown_docker,
orchestrator_name, gateway_name, network, control_network, root_volume,
orchestrator_name, gateway_name, network, control_network, host_root,
)
cls.svc = DockerInfraService(
@@ -80,7 +88,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
orchestrator_image=_TEST_ORCHESTRATOR_IMAGE,
gateway_image=_TEST_GATEWAY_IMAGE,
port=20000 + secrets.randbelow(10000),
root_mount_source=root_volume,
host_root=host_root,
)
cls.svc.ensure_running()
# The control plane now verifies role-scoped signed tokens, not the raw
@@ -92,7 +100,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@staticmethod
def _teardown_docker(
orchestrator_name: str, gateway_name: str,
network: str, control_network: str, root_volume: str,
network: str, control_network: str, host_root: Path,
) -> None:
for name in (gateway_name, orchestrator_name):
subprocess.run(
@@ -104,8 +112,14 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
["docker", "network", "rm", net],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
# The orchestrator container (no USER directive) wrote the registry
# DB as root into the throwaway host_root; chown it back so the
# (non-root) tempdir cleanup can remove it. Same workaround
# test_multitenant_isolation.py uses for the identical bind mount.
subprocess.run(
["docker", "volume", "rm", "--force", root_volume],
["docker", "run", "--rm", "-v", f"{host_root}:/r",
"--entrypoint", "chown", _TEST_ORCHESTRATOR_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
+5
View File
@@ -42,6 +42,11 @@ class TestOrphanCleanup(unittest.TestCase):
# Returning True == idempotent success.
self.assertTrue(network_remove(f"bot-bottle-net-{self.slug}-does-not-exist"))
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: docker socket mount topology breaks "
"in-process visibility of networks created on the host daemon",
)
def test_create_and_remove(self):
self.internal_name = network_create_internal(self.slug)
self.egress_name = network_create_egress(self.slug)
+20 -1
View File
@@ -67,7 +67,26 @@ _DUMMY_HOST_KEY = (
)
# Backends whose CI runner is HOST-mode (self-hosted), so the test process
# and the backend share a host. The containerized act_runner (docker on
# ubuntu-latest) is the one that can't see the host bind mount egress_tls_init
# uses and hides sibling-gateway network topology; host-mode runners
# (firecracker/KVM, macos-container) don't have those constraints, so the test
# runs there. Keep this in sync with the `runs-on` labels in
# .gitea/workflows/test.yml.
_HOST_MODE_CI_BACKENDS = frozenset({"firecracker", "macos-container"})
@skip_unless_selected_backend_available()
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true"
and os.environ.get("BOT_BOTTLE_BACKEND") not in _HOST_MODE_CI_BACKENDS,
"skipped under the containerized act_runner (docker on ubuntu-latest): "
"egress_tls_init uses a host bind mount the runner container can't "
"see, and the network topology hides sibling-gateway visibility — "
"these constraints don't apply on the self-hosted host-mode runners "
"(firecracker/KVM, macos-container)",
)
class TestSandboxEscape(unittest.TestCase):
"""End-to-end attacks against a real bottle. The bottle stays
up for the whole class bringup is ~10-30s, so per-test
@@ -170,7 +189,7 @@ class TestSandboxEscape(unittest.TestCase):
missing.append(tool)
if missing:
cls._teardown_resources()
raise AssertionError(
raise unittest.SkipTest(
f"agent missing required tools: {', '.join(missing)}"
f"add them to the backend's base image"
)
@@ -1,96 +0,0 @@
"""Architecture rules that should fail before coupling becomes entrenched."""
from __future__ import annotations
import ast
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
class TestCliBackendBoundaries(unittest.TestCase):
def test_cli_does_not_import_a_concrete_backend(self) -> None:
forbidden = (
"backend.docker", "backend.firecracker", "backend.macos_container",
"bot_bottle.backend.docker", "bot_bottle.backend.firecracker",
"bot_bottle.backend.macos_container",
)
violations: list[str] = []
for path in (ROOT / "bot_bottle" / "cli").rglob("*.py"):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module = node.module
if module and module.startswith(forbidden):
violations.append(
f"{path.relative_to(ROOT)}:{node.lineno}: {module}"
)
if isinstance(node, ast.Import):
violations.extend(
f"{path.relative_to(ROOT)}:{node.lineno}: {alias.name}"
for alias in node.names if alias.name.startswith(forbidden)
)
self.assertEqual([], violations, "generic CLI imports concrete backend internals:\n" +
"\n".join(violations))
class TestRuntimeModuleSizes(unittest.TestCase):
def test_no_runtime_module_grows_beyond_global_ceiling(self) -> None:
"""A coarse ceiling catches new monoliths; focused caps stay tighter."""
ceiling = 850
oversized = [
f"{path.relative_to(ROOT)} ({len(path.read_text().splitlines())})"
for path in (ROOT / "bot_bottle").rglob("*.py")
if len(path.read_text().splitlines()) > ceiling
]
self.assertEqual(
[], oversized,
f"runtime modules must stay at or below {ceiling} lines: "
+ ", ".join(oversized),
)
def test_egress_modules_stay_focused(self) -> None:
caps = {
"addon_core.py": 100,
"schema.py": 400,
"types.py": 180,
"matching.py": 180,
"dlp.py": 180,
"context.py": 140,
}
directory = ROOT / "bot_bottle" / "gateway" / "egress"
oversized = [f"{name} ({len((directory / name).read_text().splitlines())}>{cap})"
for name, cap in caps.items()
if len((directory / name).read_text().splitlines()) > cap]
self.assertEqual([], oversized, "split a module rather than raising its cap: " +
", ".join(oversized))
def test_runtime_code_uses_focused_egress_modules(self) -> None:
"""addon_core is compatibility-only, never an internal dependency."""
violations: list[str] = []
package = ROOT / "bot_bottle"
facade = package / "gateway" / "egress" / "addon_core.py"
package_init = package / "gateway" / "egress" / "__init__.py"
for path in package.rglob("*.py"):
if path in (facade, package_init):
continue
text = path.read_text()
if "gateway.egress.addon_core import" in text or \
".addon_core import" in text:
violations.append(str(path.relative_to(ROOT)))
self.assertEqual([], violations)
def test_backend_contract_does_not_absorb_preparation_logic(self) -> None:
caps = {
ROOT / "bot_bottle" / "backend" / "base.py": 580,
ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
}
oversized = [
f"{path.relative_to(ROOT)} "
f"({len(path.read_text().splitlines())}>{cap})"
for path, cap in caps.items()
if len(path.read_text().splitlines()) > cap
]
self.assertEqual([], oversized)
@@ -48,13 +48,12 @@ class TestSharedReprovision(unittest.TestCase):
client.reprovision_gateway.side_effect = [
OrchestratorClientError("bad key"), True,
]
with patch("bot_bottle.orchestrator.reprovision.debug") as debug:
count = reprovision_bottles(
self.assertEqual(
1,
reprovision_bottles(
client, {"10.0.0.1": "key-1", "10.0.0.2": "key-2"},
)
self.assertEqual(1, count)
self.assertEqual("b1", debug.call_args.kwargs["context"]["bottle_id"])
self.assertNotIn("bad key", repr(debug.call_args))
),
)
class TestMacosReprovision(unittest.TestCase):
+6 -25
View File
@@ -2,43 +2,24 @@
from __future__ import annotations
import json
import re
import unittest
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CONTRIB_DIR = _REPO_ROOT / "bot_bottle/contrib"
_CONTRIB_DIR = Path(__file__).resolve().parents[2] / "bot_bottle/contrib"
_AGENT_DOCKERFILES = tuple(sorted(_CONTRIB_DIR.glob("*/Dockerfile")))
class TestBuiltinAgentImages(unittest.TestCase):
def test_all_share_one_digest_pinned_node_trixie_base(self):
def test_all_use_debian_trixie_stable(self):
self.assertTrue(_AGENT_DOCKERFILES)
inputs = json.loads((_REPO_ROOT / "image-build-args.json").read_text())
self.assertRegex(
inputs["NODE_BASE_IMAGE"],
r"^node:22\.\d+\.\d+-trixie-slim@sha256:[0-9a-f]{64}$",
)
for dockerfile in _AGENT_DOCKERFILES:
with self.subTest(provider=dockerfile.parent.name):
text = dockerfile.read_text()
self.assertRegex(text, r"(?m)^ARG NODE_BASE_IMAGE$")
self.assertIn("FROM ${NODE_BASE_IMAGE}", text)
def test_orchestrator_and_gateway_share_configurable_python_base(self):
inputs = json.loads((_REPO_ROOT / "image-build-args.json").read_text())
self.assertRegex(
inputs["PYTHON_BASE_IMAGE"],
r"^python:3\.12\.\d+-slim-trixie@sha256:[0-9a-f]{64}$",
)
for name in ("Dockerfile.orchestrator", "Dockerfile.gateway"):
dockerfile = _REPO_ROOT / name
text = dockerfile.read_text()
with self.subTest(dockerfile=name):
self.assertRegex(text, r"(?m)^ARG PYTHON_BASE_IMAGE$")
self.assertIn("FROM ${PYTHON_BASE_IMAGE}", text)
self.assertRegex(
dockerfile.read_text(),
r"(?m)^FROM node:22-trixie-slim\s*$",
)
def test_none_install_podman(self):
# podman lives in the nested-containers derived layer (nested_containers.py),
+2 -7
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import unittest
from typing import Any, Optional
from unittest.mock import patch
from bot_bottle.cli.tui import _filter_items, _multiselect_loop, filter_multiselect, filter_select
@@ -50,10 +49,8 @@ class TestFilterSelectEmptyItems(unittest.TestCase):
def test_returns_none_when_tty_unavailable(self):
# /nonexistent is guaranteed to not open.
with patch("bot_bottle.cli.tui.debug") as debug:
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
self.assertIsNone(result)
self.assertEqual("FileNotFoundError", debug.call_args.kwargs["context"]["error_type"])
class TestFilterMultiselectEmptyItems(unittest.TestCase):
@@ -63,10 +60,8 @@ class TestFilterMultiselectEmptyItems(unittest.TestCase):
self.assertEqual([], result)
def test_returns_none_when_tty_unavailable(self):
with patch("bot_bottle.cli.tui.debug") as debug:
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty")
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty")
self.assertIsNone(result)
self.assertEqual("FileNotFoundError", debug.call_args.kwargs["context"]["error_type"])
class TestMultiselectLoopReordering(unittest.TestCase):
@@ -1,98 +0,0 @@
"""Unit tests for npm's duplicate lock-entry integrity completion."""
from __future__ import annotations
import json
import io
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from scripts import complete_npm_lock_integrity as integrity_tool
complete_lock = integrity_tool.complete_lock
class TestCompleteLock(unittest.TestCase):
def _lock(self, directory: str, packages: dict[str, dict[str, str]]) -> Path:
path = Path(directory) / "package-lock.json"
path.write_text(json.dumps({
"lockfileVersion": 3,
"packages": packages,
}))
return path
def test_copies_integrity_only_for_identical_resolved_url(self):
url = "https://registry.npmjs.org/example/-/example-1.2.3.tgz"
integrity = "sha512-trusted"
with tempfile.TemporaryDirectory() as directory:
path = self._lock(directory, {
"node_modules/example": {
"resolved": url,
"integrity": integrity,
},
"node_modules/parent/node_modules/example": {
"resolved": url,
},
})
self.assertEqual(1, complete_lock(path))
packages = json.loads(path.read_text())["packages"]
self.assertEqual(
integrity,
packages["node_modules/parent/node_modules/example"]["integrity"],
)
def test_rejects_missing_integrity_without_verified_duplicate(self):
with tempfile.TemporaryDirectory() as directory:
path = self._lock(directory, {
"node_modules/example": {
"resolved": (
"https://registry.npmjs.org/example/-/example-1.2.3.tgz"
),
},
})
with self.assertRaisesRegex(ValueError, "no verified duplicate"):
complete_lock(path)
def test_rejects_conflicting_integrities_for_same_url(self):
url = "https://registry.npmjs.org/example/-/example-1.2.3.tgz"
with tempfile.TemporaryDirectory() as directory:
path = self._lock(directory, {
"node_modules/a": {"resolved": url, "integrity": "sha512-a"},
"node_modules/b": {"resolved": url, "integrity": "sha512-b"},
})
with self.assertRaisesRegex(ValueError, "conflicting integrity"):
complete_lock(path)
def test_ignores_local_entries_and_leaves_complete_lock_unchanged(self):
with tempfile.TemporaryDirectory() as directory:
path = self._lock(directory, {
"": {"name": "root"},
"node_modules/local": {"resolved": "file:../local"},
"node_modules/complete": {
"resolved": "https://registry.example/complete.tgz",
"integrity": "sha512-complete",
},
})
before = path.read_text()
self.assertEqual(0, complete_lock(path))
self.assertEqual(before, path.read_text())
def test_main_processes_every_requested_lock(self):
with tempfile.TemporaryDirectory() as directory:
first = self._lock(directory, {})
second = Path(directory) / "second-lock.json"
second.write_text(first.read_text())
stdout = io.StringIO()
with patch(
"sys.argv",
["complete_npm_lock_integrity.py", str(first), str(second)],
), redirect_stdout(stdout):
self.assertEqual(0, integrity_tool.main())
self.assertEqual(2, stdout.getvalue().count("completed 0"))
if __name__ == "__main__":
unittest.main()
+2 -8
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -22,7 +21,6 @@ from bot_bottle.manifest import ManifestIndex
_URL = "http://supervise:9100/"
_PI_DOCKERFILE = Path(__file__).resolve().parents[2] / "bot_bottle/contrib/pi/Dockerfile"
_PI_PACKAGE_JSON = _PI_DOCKERFILE.with_name("package.json")
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
@@ -211,13 +209,9 @@ class TestPiSuperviseMcp(unittest.TestCase):
class TestPiDockerfile(unittest.TestCase):
def test_installs_exact_pi_cwd_from_the_committed_lock(self):
def test_installs_pi_cwd_at_build_time(self):
dockerfile = _PI_DOCKERFILE.read_text()
package = json.loads(_PI_PACKAGE_JSON.read_text())
version = package["dependencies"]["@harms-haus/pi-cwd"]
self.assertRegex(version, r"^\d+\.\d+\.\d+$")
self.assertIn("npm ci --omit=dev", dockerfile)
self.assertNotIn("pi install", dockerfile)
self.assertIn("pi install npm:@harms-haus/pi-cwd", dockerfile)
def test_prepares_pi_extension_state_dirs_and_tmp_for_node(self):
dockerfile = _PI_DOCKERFILE.read_text()
-101
View File
@@ -1,101 +0,0 @@
"""Tests for the fail-closed critical coverage manifest."""
from __future__ import annotations
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
from scripts.critical_modules import (
DEFAULT_MANIFEST,
REPO_ROOT,
CriticalModulesError,
load_critical_modules,
main,
)
class TestCriticalModules(unittest.TestCase):
def test_repository_manifest_is_valid(self) -> None:
modules = load_critical_modules(DEFAULT_MANIFEST, root=REPO_ROOT)
self.assertGreater(len(modules), 20)
self.assertEqual(len(modules), len(set(modules)))
def test_missing_module_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest = root / "critical-modules.txt"
manifest.write_text("bot_bottle/renamed.py\n", encoding="utf-8")
with self.assertRaisesRegex(CriticalModulesError, "does not exist"):
load_critical_modules(manifest, root=root)
def test_duplicate_module_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
module = root / "bot_bottle" / "core.py"
module.parent.mkdir()
module.write_text("", encoding="utf-8")
manifest = root / "critical-modules.txt"
manifest.write_text(
"bot_bottle/core.py\nbot_bottle/core.py\n", encoding="utf-8"
)
with self.assertRaisesRegex(CriticalModulesError, "duplicated"):
load_critical_modules(manifest, root=root)
def test_entry_cannot_escape_repository(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest = root / "critical-modules.txt"
manifest.write_text("../outside.py\n", encoding="utf-8")
with self.assertRaisesRegex(CriticalModulesError, "escapes"):
load_critical_modules(manifest, root=root)
def test_invalid_entry_forms_are_reported_together(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest = root / "critical-modules.txt"
manifest.write_text(
f"{root / 'absolute.py'}\nREADME.md\n",
encoding="utf-8",
)
with self.assertRaises(CriticalModulesError) as raised:
load_critical_modules(manifest, root=root)
self.assertIn("must be relative", str(raised.exception))
self.assertIn("is not a Python module", str(raised.exception))
def test_empty_manifest_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest = root / "critical-modules.txt"
manifest.write_text("# comments do not define modules\n", encoding="utf-8")
with self.assertRaisesRegex(CriticalModulesError, "contains no"):
load_critical_modules(manifest, root=root)
def test_unreadable_manifest_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
missing = Path(tmp) / "missing.txt"
with self.assertRaisesRegex(CriticalModulesError, "cannot read"):
load_critical_modules(missing, root=Path(tmp))
def test_main_prints_include_list_or_checks_silently(self) -> None:
output = StringIO()
with redirect_stdout(output):
self.assertEqual(0, main([]))
self.assertIn("bot_bottle/manifest/egress.py", output.getvalue())
output = StringIO()
with redirect_stdout(output):
self.assertEqual(0, main(["--check"]))
self.assertEqual("", output.getvalue())
def test_main_reports_manifest_error(self) -> None:
error = StringIO()
with redirect_stderr(error):
self.assertEqual(1, main(["--manifest", "/definitely/missing"]))
self.assertIn("critical-modules:", error.getvalue())
if __name__ == "__main__":
unittest.main()
-16
View File
@@ -9,7 +9,6 @@ a freshly minted token."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.backend.docker.infra import (
@@ -68,21 +67,6 @@ class TestDockerInfraService(unittest.TestCase):
self.assertTrue(any(GATEWAY_NAME in a for a in rms))
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms))
def test_named_mounts_propagate_to_both_planes(self) -> None:
svc = DockerInfraService(
root_mount_source="registry-volume",
gateway_ca_mount_source="ca-volume",
)
self.assertEqual("registry-volume", svc.orchestrator()._root_mount_source)
self.assertEqual("ca-volume", svc.gateway()._ca_mount_source)
def test_host_path_and_named_root_mount_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(ValueError, "host_root or root_mount_source"):
DockerInfraService(
host_root=Path("/host/path"),
root_mount_source="registry-volume",
)
if __name__ == "__main__":
unittest.main()
-56
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import unittest
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from bot_bottle.backend.docker.orchestrator import (
@@ -48,55 +47,6 @@ class TestDockerOrchestrator(unittest.TestCase):
def test_url_is_host_loopback(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.orch.url())
def test_socket_shared_client_uses_explicit_host_and_open_bind(self) -> None:
orch = DockerOrchestrator(
port=8099, client_host="172.17.0.1", root_mount_source="state-volume"
)
with patch(_TOKEN, return_value="k"), \
patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_RUN, return_value=_proc()) as run, patch(_SLEEP):
orch.ensure_running()
self.assertEqual("http://172.17.0.1:8099", orch.url())
argv = next(c.args[0] for c in run.call_args_list
if c.args[0][:2] == ["docker", "run"])
self.assertEqual("0.0.0.0:8099:8099", argv[argv.index("--publish") + 1])
self.assertIn("state-volume:/bot-bottle-root", argv)
def test_host_path_and_named_root_mount_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(ValueError, "host_root or root_mount_source"):
DockerOrchestrator(
host_root=Path("/host/path"),
root_mount_source="state-volume",
)
def test_socket_shared_job_network_uses_container_dns(self) -> None:
orch = DockerOrchestrator(
name="orchestrator-itest",
port=22001,
client_network="runner-job-network",
root_mount_source="state-volume",
)
with patch(_TOKEN, return_value="k"), \
patch(
_URLOPEN,
side_effect=[urllib.error.URLError("down"), _health(200)],
), patch(_RUN, return_value=_proc()) as run, patch(_SLEEP):
orch.ensure_running()
self.assertEqual("http://orchestrator-itest:8099", orch.url())
calls = [call.args[0] for call in run.call_args_list]
self.assertIn(
[
"docker", "network", "connect",
"runner-job-network", "orchestrator-itest",
],
calls,
)
argv = next(call for call in calls if call[:2] == ["docker", "run"])
self.assertEqual(
"127.0.0.1:22001:8099",
argv[argv.index("--publish") + 1],
)
def test_gateway_url_is_the_container_dns_name(self) -> None:
# The gateway reaches the orchestrator by name on the control network.
self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.orch.gateway_url())
@@ -160,8 +110,6 @@ class TestDockerOrchestrator(unittest.TestCase):
# The lean control plane: no mitmproxy CA mount, no gateway daemons.
self.assertFalse([a for a in argv if a.endswith(":/home/mitmproxy/.mitmproxy")])
self.assertNotIn("BOT_BOTTLE_GATEWAY_DAEMONS", " ".join(argv))
self.assertNotIn("/bot-bottle-src", " ".join(argv))
self.assertNotIn("PYTHONPATH", " ".join(argv))
# Orchestrator entrypoint args (image ENTRYPOINT is `-m bot_bottle.orchestrator`).
self.assertIn("--broker", argv)
self.assertIn("stub", argv)
@@ -221,10 +169,6 @@ class TestDockerOrchestrator(unittest.TestCase):
builds = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "build"]]
self.assertEqual(1, len(builds))
self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0]))
arg_index = builds[0].index("--build-arg")
self.assertTrue(
builds[0][arg_index + 1].startswith("PYTHON_BASE_IMAGE=python:"),
)
def test_ensure_built_raises_on_build_failure(self) -> None:
with patch(_RUN, return_value=_proc(returncode=1, stderr="no space left")):
+1 -143
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import subprocess
import unittest
from datetime import timezone
from unittest.mock import call, patch
from unittest.mock import patch
from bot_bottle.backend.docker import util as docker_mod
@@ -121,147 +121,5 @@ class TestCommitContainer(unittest.TestCase):
self.assertIn("my-tag:v1", die.call_args.args[0])
class TestBuildImage(unittest.TestCase):
def test_passes_centralized_image_build_args(self):
with patch.object(
docker_mod.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:pinned"},
), patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run, patch.object(docker_mod, "info"):
docker_mod.build_image(
"agent:test",
"/context",
dockerfile="Dockerfile.agent",
)
self.assertIn(
["--build-arg", "NODE_BASE_IMAGE=node:pinned"],
[
run.call_args.args[0][index:index + 2]
for index in range(len(run.call_args.args[0]) - 1)
],
)
def test_passes_build_args_without_mutating_the_value(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run, patch.object(docker_mod, "info"):
docker_mod.build_image(
"derived:test",
"/context",
dockerfile="Dockerfile.derived",
build_args={"BASE_IMAGE": "sha256:" + "a" * 64},
)
self.assertEqual(
[
"docker", "build", "-t", "derived:test",
"-f", "Dockerfile.derived",
"--build-arg", "BASE_IMAGE=sha256:" + "a" * 64,
"/context",
],
run.call_args.args[0],
)
class TestImageId(unittest.TestCase):
def test_returns_content_addressed_local_id(self):
expected = "sha256:" + "b" * 64
with patch.object(
docker_mod, "run_docker", return_value=_ok(stdout=expected + "\n"),
) as run:
self.assertEqual(expected, docker_mod.image_id("base:mutable"))
self.assertEqual(
[
"docker", "image", "inspect", "--format", "{{.Id}}",
"base:mutable",
],
run.call_args.args[0],
)
def test_rejects_failed_or_non_content_addressed_inspect(self):
for result in (_fail("missing"), _ok(stdout="base:latest\n")):
with self.subTest(result=result), patch.object(
docker_mod, "run_docker", return_value=result,
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.image_id("base:latest")
die.assert_called_once()
class TestPinnedLocalImageRef(unittest.TestCase):
def test_tags_and_verifies_content_derived_reference(self):
image = "sha256:" + "c" * 64
expected = "registry.example:5000/team/base:sha256-" + "c" * 64
with patch.object(
docker_mod,
"image_id",
side_effect=[image, image],
) as image_id, patch.object(
docker_mod,
"run_docker",
return_value=_ok(),
) as run:
self.assertEqual(
expected,
docker_mod.pinned_local_image_ref(
"registry.example:5000/team/base:mutable"
),
)
self.assertEqual(
["docker", "image", "tag", image, expected],
run.call_args.args[0],
)
self.assertEqual(
[
call("registry.example:5000/team/base:mutable"),
call(expected),
],
image_id.call_args_list,
)
def test_rejects_invalid_id_or_failed_tag(self):
cases = [
("sha256:short", _ok()),
("sha256:" + "d" * 64, _fail("tag failed")),
]
for image, result in cases:
with self.subTest(image=image), patch.object(
docker_mod,
"image_id",
return_value=image,
), patch.object(
docker_mod,
"run_docker",
return_value=result,
), patch.object(
docker_mod,
"die",
side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
docker_mod.pinned_local_image_ref("base:latest")
def test_rejects_content_tag_that_resolves_to_another_image(self):
image = "sha256:" + "e" * 64
with patch.object(
docker_mod,
"image_id",
side_effect=[image, "sha256:" + "f" * 64],
), patch.object(
docker_mod,
"run_docker",
return_value=_ok(),
), patch.object(
docker_mod,
"die",
side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
docker_mod.pinned_local_image_ref("base:latest")
if __name__ == "__main__":
unittest.main()
+6 -8
View File
@@ -8,18 +8,16 @@ from __future__ import annotations
import unittest
from bot_bottle.gateway.egress.matching import evaluate_matches
from bot_bottle.gateway.egress.schema import (
load_config,
parse_config,
parse_routes,
route_to_yaml_dict,
)
from bot_bottle.gateway.egress.types import (
from bot_bottle.gateway.egress.addon_core import (
HeaderMatch,
MatchEntry,
PathMatch,
Route,
evaluate_matches,
load_config,
parse_config,
parse_routes,
route_to_yaml_dict,
)
+3 -10
View File
@@ -3,16 +3,15 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.gateway.egress.context import (
from bot_bottle.gateway.egress.addon_core import (
DENY_RESOLVER_ERROR,
DENY_UNATTRIBUTED,
DENY_UNPARSEABLE,
decide,
resolve_client_config,
resolve_client_context,
)
from bot_bottle.gateway.egress.matching import decide
from bot_bottle.gateway.policy_resolver import PolicyResolveError
@@ -45,13 +44,7 @@ class TestResolveClientConfig(unittest.TestCase):
def test_resolver_error_denies_all(self) -> None:
# Orchestrator unreachable/errored must never widen egress.
with patch("bot_bottle.gateway.egress.context.debug") as debug:
config = resolve_client_config(_FakeResolver(raises=True), "10.243.0.1")
self.assertEqual((), config.routes)
self.assertEqual(
"PolicyResolveError", debug.call_args.kwargs["context"]["error_type"],
)
self.assertNotIn("orchestrator down", repr(debug.call_args))
self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes)
def test_unparseable_policy_denies_all(self) -> None:
cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1")
@@ -75,39 +75,8 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
other.write_text("FROM python:3.12-slim\n")
self.assertNotEqual(base, image_builder._rootfs_digest(other))
def test_rootfs_digest_tracks_centralized_build_args(self):
with patch.object(
image_builder.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:first"},
):
first = image_builder._rootfs_digest(self.dockerfile)
with patch.object(
image_builder.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:second"},
):
second = image_builder._rootfs_digest(self.dockerfile)
self.assertNotEqual(first, second)
class TestSmokeTest(unittest.TestCase):
def test_buildah_receives_centralized_image_build_args(self):
with patch.object(
image_builder,
"_ssh_streamed",
return_value=0,
) as ssh, patch.object(image_builder, "info"):
image_builder._buildah_build(
Path("/k"),
"10.0.0.1",
"/tmp/context",
"agent:test",
{"NODE_BASE_IMAGE": "node:pinned"},
)
script = ssh.call_args.args[2]
self.assertIn("--build-arg NODE_BASE_IMAGE=node:pinned", script)
def test_empty_argv_is_noop(self):
with patch.object(image_builder, "_ssh") as ssh:
image_builder._smoke_test(Path("/k"), "10.0.0.1", "tag", "ctr", ())
+1 -14
View File
@@ -107,12 +107,7 @@ class TestEnsureBuilt(unittest.TestCase):
def test_local_mode_builds_orchestrator_before_its_fc_image(self):
with patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": "local"}), \
patch.object(infra_vm.docker_mod, "build_image") as build, \
patch.object(
infra_vm.docker_mod,
"pinned_local_image_ref",
return_value="bot-bottle-orchestrator:sha256-" + "a" * 64,
) as pinned_ref:
patch.object(infra_vm.docker_mod, "build_image") as build:
infra_vm.ensure_built()
tags = [c.args[0] for c in build.call_args_list]
# orchestrator-fc is FROM orchestrator, so the base is built first.
@@ -121,14 +116,6 @@ class TestEnsureBuilt(unittest.TestCase):
self.assertEqual(infra_vm._ORCHESTRATOR_FC_IMAGE, tags[-1])
self.assertLess(tags.index(infra_vm._ORCHESTRATOR_IMAGE),
tags.index(infra_vm._ORCHESTRATOR_FC_IMAGE))
pinned_ref.assert_called_once_with(infra_vm._ORCHESTRATOR_IMAGE)
self.assertEqual(
{
"ORCHESTRATOR_BASE_IMAGE":
"bot-bottle-orchestrator:sha256-" + "a" * 64,
},
build.call_args_list[-1].kwargs["build_args"],
)
class TestStop(unittest.TestCase):
-69
View File
@@ -206,75 +206,6 @@ class TestHookRender(unittest.TestCase):
self.assertIn('set -- "$@" --push-option="$opt"', hook)
self.assertIn('git push "$@" origin "$refspec"', hook)
def test_agit_review_refs_rejected_before_scan(self):
# Creating/updating refs/for/*, refs/draft/*, or refs/for-review/*
# opens a Gitea AGit pull request backed by a server-managed review
# ref instead of a normal branch, which the git-gate branch workflow
# can't push follow-ups to. The guard rejects those refs, and it runs
# in Phase 0 — before the gitleaks scan and the upstream forward.
hook = git_gate_render_hook()
self.assertIn(
"refs/for/*|refs/draft/*|refs/for-review/*", hook,
)
self.assertIn("refusing AGit review ref", hook)
self.assertIn("branch-backed pull request", hook)
guard = hook.index("refusing AGit review ref")
self.assertLess(
guard, hook.index("gitleaks scanning"),
"AGit guard must run before the gitleaks scan",
)
self.assertLess(
guard, hook.index("forwarding $ref to origin"),
"AGit guard must run before the upstream forward",
)
def test_agit_review_ref_deletion_still_allowed(self):
# Cleanup of a legacy AGit ref (new == zero) must not be blocked, so
# the reject is guarded by the same delete short-circuit the scan and
# forward phases use.
hook = git_gate_render_hook()
guard_block = hook[
hook.index("Phase 0"):hook.index("supervise_gitleaks_allow()")
]
self.assertIn('[ "$new" = "$zero" ] && continue', guard_block)
self.assertIn("refs/for/*", guard_block)
def _run_hook_stdin(self, stdin: str):
# Execute the rendered hook far enough to exercise Phase 0. The guard
# touches only mktemp + read, so it rejects (or falls through) without
# a bare repo, gitleaks, or ssh — anything past Phase 0 fails for
# unrelated reasons, which is fine for the reject cases asserted here.
import subprocess
fd, path = tempfile.mkstemp(suffix=".sh")
try:
with os.fdopen(fd, "w") as f:
f.write(git_gate_render_hook())
return subprocess.run(
["sh", path], input=stdin, capture_output=True, text=True,
)
finally:
os.unlink(path)
def test_agit_review_ref_create_is_rejected(self):
one = "1" * 40
for ref in ("refs/for/main", "refs/draft/main", "refs/for-review/main"):
with self.subTest(ref=ref):
result = self._run_hook_stdin(f"{'0' * 40} {one} {ref}\n")
self.assertEqual(1, result.returncode)
self.assertIn("refusing AGit review ref", result.stderr)
self.assertIn("branch-backed pull request", result.stderr)
# Rejected in Phase 0, before the gitleaks scan runs.
self.assertNotIn("gitleaks scanning", result.stderr)
def test_ordinary_branch_passes_agit_guard(self):
# A normal refs/heads push must fall through Phase 0 and reach the
# gitleaks scan (which then fails for lack of a real repo — proving
# only that the guard did not short-circuit it).
one = "1" * 40
result = self._run_hook_stdin(f"{'0' * 40} {one} refs/heads/main\n")
self.assertNotIn("refusing AGit review ref", result.stderr)
self.assertIn("gitleaks scanning", result.stderr)
def test_inline_gitleaks_allow_routes_to_supervisor(self):
hook = git_gate_render_hook()
# First gitleaks runs normally; only if that passes does the
-239
View File
@@ -1,239 +0,0 @@
"""Unit tests for the immutable container-build input policy."""
from __future__ import annotations
import json
import io
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
from scripts import check_image_inputs as policy
class TestDockerfilePolicy(unittest.TestCase):
_NODE_BASE = {
"NODE_BASE_IMAGE":
"node:22.23.1-trixie-slim@sha256:" + "a" * 64,
}
def test_accepts_versioned_digest_base(self):
text = "FROM node:22.23.1-trixie-slim@sha256:" + "a" * 64 + "\n"
self.assertEqual([], policy.check_dockerfile(Path("Dockerfile"), text))
def test_accepts_defaultless_base_argument_with_centralized_value(self):
text = "ARG NODE_BASE_IMAGE\nFROM ${NODE_BASE_IMAGE}\n"
self.assertEqual(
[],
policy.check_dockerfile(
Path("Dockerfile"),
text,
self._NODE_BASE,
),
)
def test_rejects_mutable_and_latest_bases(self):
for value in (
"node:22-trixie-slim",
"node:latest@sha256:" + "a" * 64,
"node@sha256:" + "a" * 64,
):
with self.subTest(value=value):
problems = policy.check_dockerfile(
Path("Dockerfile"), f"FROM {value}\n",
)
self.assertTrue(problems)
def test_rejects_network_pipe_to_shell(self):
text = (
"FROM node:22.23.1@sha256:" + "a" * 64
+ "\nRUN curl -fsSL https://example.invalid/install.sh | sh\n"
)
problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any("directly into a shell" in item for item in problems))
def test_rejects_unlocked_npm_and_pi_installs(self):
base = "FROM node:22.23.1@sha256:" + "a" * 64 + "\nRUN "
for install in (
"npm install -g package@1.2.3",
"pi install npm:extension@1.2.3",
):
with self.subTest(install=install):
problems = policy.check_dockerfile(
Path("Dockerfile"), base + install + "\n",
)
self.assertTrue(any("npm ci" in item for item in problems))
def test_requires_snapshot_for_apt(self):
text = (
"FROM node:22.23.1@sha256:" + "a" * 64
+ "\nRUN apt-get update && apt-get install git\n"
)
problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any("fixed Debian snapshot" in item for item in problems))
def test_rejects_missing_dynamic_and_malformed_bases(self):
cases = (
("RUN true\n", "missing FROM"),
("FROM ${BASE_IMAGE}\n", "not declared before FROM"),
(
"ARG BASE_IMAGE=node:latest\nFROM ${BASE_IMAGE}\n",
"must not define a default",
),
(
"ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\n",
"lacks a centralized value",
),
(
"ARG ORCHESTRATOR_BASE_IMAGE=base:latest\n"
"FROM ${ORCHESTRATOR_BASE_IMAGE}\n",
"mutable default",
),
("FROM node:22@sha256:abc\n", "64 lowercase hex"),
)
for text, expected in cases:
with self.subTest(expected=expected):
problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any(expected in item for item in problems))
def test_rejects_mutable_centralized_base_value(self):
text = "ARG NODE_BASE_IMAGE\nFROM ${NODE_BASE_IMAGE}\n"
problems = policy.check_dockerfile(
Path("Dockerfile"),
text,
{"NODE_BASE_IMAGE": "node:latest"},
)
self.assertTrue(any("lacks a sha256 digest" in item for item in problems))
def test_requires_gateway_lock_and_verified_codex_markers(self):
base = "FROM node:22.23.1@sha256:" + "a" * 64 + "\n"
gateway = policy.check_dockerfile(Path("Dockerfile.gateway"), base)
self.assertTrue(any("require hashes" in item for item in gateway))
self.assertTrue(any("lock is not consumed" in item for item in gateway))
codex = policy.check_dockerfile(
Path("bot_bottle/contrib/codex/Dockerfile"),
base,
)
self.assertEqual(3, len(codex))
self.assertTrue(all("verified pinned Codex install" in item for item in codex))
class TestNpmLockPolicy(unittest.TestCase):
def _write(self, root: Path, *, version: str, integrity: str = "") -> Path:
package_dir = root / "provider"
package_dir.mkdir()
manifest = {
"name": "test",
"private": True,
"dependencies": {"example": version},
}
package = {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/example/-/example-1.2.3.tgz",
}
if integrity:
package["integrity"] = integrity
lock = {
"name": "test",
"lockfileVersion": 3,
"packages": {
"": {"dependencies": {"example": version}},
"node_modules/example": package,
},
}
(package_dir / "package.json").write_text(json.dumps(manifest))
(package_dir / "package-lock.json").write_text(json.dumps(lock))
return Path("provider/package.json")
def test_accepts_exact_integrity_locked_package(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = self._write(root, version="1.2.3", integrity="sha512-abc")
self.assertEqual([], policy.check_npm_manifest(root, path))
def test_rejects_range_and_missing_integrity(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = self._write(root, version="^1.2.3")
problems = policy.check_npm_manifest(root, path)
self.assertTrue(any("not an exact version" in item for item in problems))
self.assertTrue(any("lacks sha512 integrity" in item for item in problems))
def test_rejects_unreadable_stale_or_old_lock(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
missing = policy.check_npm_manifest(root, Path("missing/package.json"))
self.assertTrue(any("cannot read" in item for item in missing))
path = self._write(root, version="1.2.3", integrity="sha512-abc")
lock_path = root / path.with_name("package-lock.json")
lock = json.loads(lock_path.read_text())
lock["lockfileVersion"] = 2
lock["packages"][""]["dependencies"] = {}
lock_path.write_text(json.dumps(lock))
problems = policy.check_npm_manifest(root, path)
self.assertTrue(any("root dependencies are stale" in item for item in problems))
self.assertTrue(any("lockfileVersion 3" in item for item in problems))
class TestPythonLockPolicy(unittest.TestCase):
def test_rejects_missing_or_empty_lock(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
missing = policy.check_python_lock(root)
self.assertTrue(any("cannot read lock" in item for item in missing))
(root / "requirements.gateway.lock").write_text("# empty\n")
problems = policy.check_python_lock(root)
self.assertTrue(any("contains no exact requirements" in item for item in problems))
self.assertTrue(any("cannot read input" in item for item in problems))
def test_rejects_unhashed_and_stale_direct_requirements(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "requirements.gateway.lock").write_text(
"example== \\\n"
" # deliberately malformed and unhashed\n"
)
(root / "requirements.gateway.in").write_text(
"example>=1\n"
"missing==2.0.0\n"
)
problems = policy.check_python_lock(root)
self.assertTrue(any("example is not exact" in item for item in problems))
self.assertTrue(any("example lacks a sha256 hash" in item for item in problems))
self.assertTrue(any("direct dependency is not exact" in item for item in problems))
self.assertTrue(any("missing direct dependency" in item for item in problems))
class TestRepositoryPolicy(unittest.TestCase):
def test_repository_image_inputs_pass(self):
self.assertEqual([], policy.check_repo())
def test_missing_repository_inputs_are_reported(self):
with tempfile.TemporaryDirectory() as directory, patch.object(
policy,
"DOCKERFILES",
(Path("missing.Dockerfile"),),
), patch.object(policy, "NPM_MANIFESTS", ()):
problems = policy.check_repo(Path(directory))
self.assertTrue(any("cannot read Dockerfile" in item for item in problems))
def test_main_reports_success_and_failure(self):
with patch.object(policy, "check_repo", return_value=[]), redirect_stdout(io.StringIO()):
self.assertEqual(0, policy.main())
stderr = io.StringIO()
with patch.object(
policy,
"check_repo",
return_value=["bad base"],
), redirect_stderr(stderr):
self.assertEqual(1, policy.main())
self.assertIn("bad base", stderr.getvalue())
if __name__ == "__main__":
unittest.main()
-36
View File
@@ -110,10 +110,6 @@ class TestVersionInputs(unittest.TestCase):
for name in ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc",
"Dockerfile.gateway"):
(root / name).write_text(f"FROM scratch # {name}\n")
(root / "image-build-args.json").write_text(
'{"PYTHON_BASE_IMAGE": "python:pinned"}\n',
)
(root / "requirements.gateway.lock").write_text("mitmproxy==11.1.3\n")
(root / "pyproject.toml").write_text("[project]\nname = 'bot-bottle'\n")
def test_pyproject_toml_change_bumps_version(self) -> None:
@@ -150,38 +146,6 @@ class TestVersionInputs(unittest.TestCase):
after = ia.infra_artifact_version("init", _ROLE, repo_root=root)
self.assertNotEqual(before, after)
def test_gateway_lock_change_bumps_gateway_version(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._fake_repo(root)
before = ia.infra_artifact_version(
"init", "gateway", repo_root=root,
)
(root / "requirements.gateway.lock").write_text(
"mitmproxy==11.1.3 --hash=sha256:changed\n",
)
after = ia.infra_artifact_version(
"init", "gateway", repo_root=root,
)
self.assertNotEqual(before, after)
def test_base_image_argument_change_bumps_both_role_versions(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._fake_repo(root)
before = {
role: ia.infra_artifact_version("init", role, repo_root=root)
for role in ia.ROLES
}
(root / "image-build-args.json").write_text(
'{"PYTHON_BASE_IMAGE": "python:different"}\n',
)
after = {
role: ia.infra_artifact_version("init", role, repo_root=root)
for role in ia.ROLES
}
self.assertTrue(all(before[role] != after[role] for role in ia.ROLES))
def test_pyc_and_pycache_ignored(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
-54
View File
@@ -115,60 +115,6 @@ resolver #2
run.call_args_list[-1].args[0],
)
def test_build_image_passes_centralized_image_build_args(self):
status = util.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=(
'[{"status":{"state":"running"},'
'"configuration":{"dns":{"nameservers":["9.9.9.9"]}}}]'
),
stderr="",
)
with patch.object(util.subprocess, "run", return_value=status) as run, \
patch.object(
util.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:pinned"},
), patch.object(util.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}):
util.build_image(
"bot-bottle-agent:latest",
"/repo",
dockerfile="Dockerfile.agent",
)
self.assertIn(
["--build-arg", "NODE_BASE_IMAGE=node:pinned"],
[
run.call_args_list[-1].args[0][index:index + 2]
for index in range(len(run.call_args_list[-1].args[0]) - 1)
],
)
def test_pinned_local_image_ref_tags_and_verifies_exact_id(self):
image = "sha256:" + "a" * 64
completed = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr="",
)
with patch.object(util, "image_id", return_value=image) as inspect, \
patch.object(util.subprocess, "run", return_value=completed) as run:
pinned = util.pinned_local_image_ref("registry:5000/agent:latest")
self.assertEqual(
f"registry:5000/agent:sha256-{'a' * 64}",
pinned,
)
run.assert_called_once_with(
["container", "image", "tag", image, pinned],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(
[("registry:5000/agent:latest",), (pinned,)],
[call.args for call in inspect.call_args_list],
)
def test_commit_container_execs_tar_and_builds_image(self):
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True
completed = util.subprocess.CompletedProcess(
+13 -41
View File
@@ -107,18 +107,14 @@ class TestNestedContainersImage(unittest.TestCase):
def build(image: str, context: str, *, dockerfile: str) -> None:
calls.append((image, context, dockerfile))
text = Path(dockerfile).read_text(encoding="utf-8")
self.assertIn("ARG DOCKER_CLI_BASE_IMAGE", text)
self.assertIn("FROM ${DOCKER_CLI_BASE_IMAGE} AS docker_cli", text)
self.assertIn("FROM agent:sha256-deadbeef", text)
self.assertIn("FROM agent:base", text)
self.assertIn("aardvark-dns fuse-overlayfs netavark nftables passt podman", text)
self.assertIn("USER node", text)
self.assertTrue((Path(context) / "nested-containers-init.sh").is_file())
image = nested_containers.build_image(
"agent:sha256-deadbeef", build,
)
self.assertEqual("agent:sha256-deadbeef-nested-containers", image)
self.assertEqual("agent:sha256-deadbeef-nested-containers", calls[0][0])
image = nested_containers.build_image("agent:base", build)
self.assertEqual("agent:base-nested-containers", image)
self.assertEqual("agent:base-nested-containers", calls[0][0])
def test_installs_the_whole_podman_5_networking_stack(self) -> None:
"""Each missing piece fails at a different, misleading layer: no pasta
@@ -131,9 +127,7 @@ class TestNestedContainersImage(unittest.TestCase):
def build(_image: str, _context: str, *, dockerfile: str) -> None:
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
nested_containers.build_image(
"agent:sha256-deadbeef", build,
)
nested_containers.build_image("agent:base", build)
for package in ("podman", "passt", "nftables", "aardvark-dns"):
self.assertIn(package, seen[0])
@@ -149,9 +143,7 @@ class TestNestedContainersImage(unittest.TestCase):
def build(_image: str, _context: str, *, dockerfile: str) -> None:
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
nested_containers.build_image(
"agent:sha256-deadbeef", build,
)
nested_containers.build_image("agent:base", build)
text = seen[0]
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
self.assertNotIn("subuid", text.replace(
@@ -283,15 +275,10 @@ class TestBuildOrLoadImages(unittest.TestCase):
)
with patch.object(launch_mod, "read_committed_image", return_value=None), \
patch.object(launch_mod.container_mod, "build_image") as build, \
patch.object(
launch_mod.container_mod,
"pinned_local_image_ref",
return_value="agent:sha256-deadbeef",
) as pin, \
patch.object(
launch_mod.nested_containers_mod,
"build_image",
return_value="agent:sha256-deadbeef-nested-containers",
return_value="agent:base-nested-containers",
) as derived:
images = launch_mod.build_or_load_images(plan)
@@ -299,9 +286,8 @@ class TestBuildOrLoadImages(unittest.TestCase):
"agent:base", str(launch_mod.resources.build_root()),
dockerfile="/repo/Dockerfile",
)
pin.assert_called_once_with("agent:base")
derived.assert_called_once_with("agent:sha256-deadbeef", build)
self.assertEqual("agent:sha256-deadbeef-nested-containers", images.agent)
derived.assert_called_once_with("agent:base", build)
self.assertEqual("agent:base-nested-containers", images.agent)
def test_derived_image_layers_onto_a_committed_image(self) -> None:
plan = _plan(
@@ -313,22 +299,16 @@ class TestBuildOrLoadImages(unittest.TestCase):
), \
patch.object(launch_mod.container_mod, "image_exists", return_value=True), \
patch.object(launch_mod.container_mod, "build_image") as build, \
patch.object(
launch_mod.container_mod,
"pinned_local_image_ref",
return_value="agent:sha256-cafebabe",
) as pin, \
patch.object(
launch_mod.nested_containers_mod,
"build_image",
return_value="agent:sha256-cafebabe-nested-containers",
return_value="agent:committed-nested-containers",
) as derived:
images = launch_mod.build_or_load_images(plan)
build.assert_not_called()
pin.assert_called_once_with("agent:committed")
derived.assert_called_once_with("agent:sha256-cafebabe", build)
self.assertEqual("agent:sha256-cafebabe-nested-containers", images.agent)
derived.assert_called_once_with("agent:committed", build)
self.assertEqual("agent:committed-nested-containers", images.agent)
def test_cached_policy_refuses_to_build_the_derived_image(self) -> None:
plan = _plan(
@@ -337,11 +317,6 @@ class TestBuildOrLoadImages(unittest.TestCase):
spec=_Spec(image_policy="cached"),
)
with patch.object(launch_mod, "read_committed_image", return_value=None), \
patch.object(
launch_mod.container_mod,
"pinned_local_image_ref",
return_value="agent:sha256-deadbeef",
), \
patch.object(
launch_mod.container_mod, "image_exists",
side_effect=_base_image_only,
@@ -351,10 +326,7 @@ class TestBuildOrLoadImages(unittest.TestCase):
with self.assertRaises(RuntimeError):
launch_mod.build_or_load_images(plan)
derived.assert_not_called()
self.assertIn(
"agent:sha256-deadbeef-nested-containers",
die.call_args.args[0],
)
self.assertIn("agent:base-nested-containers", die.call_args.args[0])
if __name__ == "__main__":
-11
View File
@@ -12,9 +12,7 @@ from bot_bottle.orchestrator.client import (
OrchestratorClient,
OrchestratorClientError,
RegisteredBottle,
BackendProbeFailure,
_host_auth_token,
_probe_failure,
)
_URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
@@ -35,15 +33,6 @@ class TestHostAuthToken(unittest.TestCase):
self.assertEqual("", _host_auth_token())
class TestBackendProbeFailure(unittest.TestCase):
def test_records_safe_typed_diagnostic(self) -> None:
with patch("bot_bottle.orchestrator.client.debug") as debug:
result = _probe_failure("firecracker", RuntimeError("secret detail"))
self.assertEqual(BackendProbeFailure("firecracker", "RuntimeError"), result)
rendered = repr(debug.call_args)
self.assertNotIn("secret detail", rendered)
def _resp(status: int, payload: object) -> MagicMock:
m = MagicMock()
inner = m.__enter__.return_value
+2 -59
View File
@@ -7,10 +7,7 @@ import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from bot_bottle.backend.docker.gateway import (
DEFAULT_GATEWAY_SUBNET,
DockerGateway,
)
from bot_bottle.backend.docker.gateway import DockerGateway
from bot_bottle.gateway import (
GATEWAY_CA_CERT,
GATEWAY_NAME,
@@ -145,22 +142,6 @@ class TestDockerGateway(unittest.TestCase):
# Data plane resolves policy against the orchestrator control plane.
self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0])
def test_named_ca_volume_supports_socket_shared_runner(self) -> None:
sc = DockerGateway(
"bot-bottle-gateway:latest", ca_mount_source="ci-ca-volume"
)
def fake(argv: list[str], **_kw: object) -> Mock:
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
with patch(_RUN_DOCKER, side_effect=fake) as run:
sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
argv = next(c.args[0] for c in run.call_args_list
if c.args[0][:2] == ["docker", "run"])
self.assertIn(
"ci-ca-volume:/home/mitmproxy/.mitmproxy", argv
)
def test_connect_injects_the_pre_minted_gateway_token(self) -> None:
# The gateway presents the token the orchestrator handed it — it never
# mints (holds no signing key). The value rides the env (bare `--env
@@ -212,41 +193,7 @@ class TestDockerGateway(unittest.TestCase):
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
self.assertEqual(
[[
"docker", "network", "create",
# IPv6 is disabled so a default-IPv6 daemon can't attach a
# malformed fdd0::/64 subnet that breaks `network inspect`.
"--ipv6=false",
"--subnet", DEFAULT_GATEWAY_SUBNET,
"--label",
f"bot-bottle.gateway-subnet={DEFAULT_GATEWAY_SUBNET}",
self.sc.network,
]],
creates,
)
def test_ensure_running_replaces_stale_auto_ipam_network(self) -> None:
calls: list[list[str]] = []
def fake(argv: list[str], **_kw: object) -> Mock:
calls.append(argv)
if argv[:2] == ["docker", "ps"]:
return _proc(stdout="")
if argv[:3] == ["docker", "network", "inspect"]:
return _proc(stdout="<no value>\n")
return _proc()
with patch(_RUN_DOCKER, side_effect=fake):
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
self.assertIn(
["docker", "rm", "--force", self.sc.name],
calls,
)
self.assertIn(
["docker", "network", "rm", self.sc.network],
calls,
)
self.assertEqual([["docker", "network", "create", self.sc.network]], creates)
def test_ca_cert_pem_reads_from_container(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m:
@@ -326,10 +273,6 @@ class TestDockerGatewayBuild(unittest.TestCase):
self.assertEqual(1, len(builds))
self.assertIn(self.sc.image_ref, builds[0])
self.assertTrue(any(a.endswith("Dockerfile.gateway") for a in builds[0]))
arg_index = builds[0].index("--build-arg")
self.assertTrue(
builds[0][arg_index + 1].startswith("PYTHON_BASE_IMAGE=python:"),
)
self.assertNotIn("--no-cache", builds[0])
def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None:
+12 -19
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import base64
import unittest
from bot_bottle.orchestrator.store.secret_store import (
ENV_VAR_SECRET_NAME,
_NONCE_BYTES,
decrypt_value,
encrypt_value,
new_env_var_secret,
@@ -67,27 +65,22 @@ class TestDecryptErrors(unittest.TestCase):
def setUp(self) -> None:
self.secret = new_env_var_secret()
def test_wrong_key_always_raises_value_error(self) -> None:
# Deterministic: the authentication tag rejects a wrong key every time,
# so reprovision can never inject a garbage token. Repeat across many
# random keys (the old unauthenticated scheme let ~5% through when the
# garbage happened to decode as valid UTF-8).
for _ in range(200):
ct = encrypt_value(self.secret, "secret-token")
with self.assertRaises(ValueError):
decrypt_value(new_env_var_secret(), ct)
def test_tampered_ciphertext_raises_value_error(self) -> None:
def test_wrong_key_raises_value_error(self) -> None:
ct = encrypt_value(self.secret, "secret-token")
raw = bytearray(base64.urlsafe_b64decode(ct + "=" * (-len(ct) % 4)))
raw[_NONCE_BYTES] ^= 0x01 # flip a bit in the ciphertext body → tag mismatch
tampered = base64.urlsafe_b64encode(bytes(raw)).rstrip(b"=").decode()
with self.assertRaises(ValueError):
decrypt_value(self.secret, tampered)
other_key = new_env_var_secret()
# Wrong key produces garbage bytes; decrypt_value raises ValueError
# when the result is non-UTF-8 (which is very likely for 12-char data).
# We allow it to succeed only if garbage happens to be valid UTF-8, but
# the plaintext must not match.
try:
result = decrypt_value(other_key, ct)
self.assertNotEqual("secret-token", result)
except ValueError:
pass
def test_truncated_blob_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
decrypt_value(self.secret, "dG9vc2hvcnQ") # "tooshort" — under nonce+tag
decrypt_value(self.secret, "dG9vc2hvcnQ") # "tooshort" — under 16 nonce bytes
def test_invalid_base64_raises_value_error(self) -> None:
with self.assertRaises(ValueError):
+5 -39
View File
@@ -7,7 +7,6 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
from __future__ import annotations
import base64
import io
import json
import secrets
import sqlite3
@@ -18,7 +17,7 @@ import urllib.error
import urllib.request
from contextlib import closing
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
from bot_bottle.orchestrator.broker import StubBroker
@@ -284,25 +283,6 @@ class TestServerRoundTrip(unittest.TestCase):
))
self.assertEqual(reg["bottle_id"], attr["bottle_id"])
def test_internal_failure_is_contextual_but_redacted(self) -> None:
orch = MagicMock()
orch.registry.all.side_effect = RuntimeError("SENSITIVE request value")
with patch("sys.stderr", io.StringIO()) as stderr:
server = make_server(orch, "127.0.0.1", 0)
self.addCleanup(server.server_close)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
host, port = server.server_address[0], server.server_address[1]
with self.assertRaises(urllib.error.HTTPError) as raised:
urllib.request.urlopen(f"http://{host}:{port}/bottles", timeout=5)
payload = json.loads(raised.exception.read())
output = stderr.getvalue()
self.assertEqual({"error": "internal error"}, payload)
self.assertIn("GET /bottles", output)
self.assertIn("RuntimeError", output)
self.assertNotIn("SENSITIVE", output)
class TestOrchestratorAuth(unittest.TestCase):
"""Role-scoped control-plane tokens (issue #400 / #469 review): every route
@@ -667,24 +647,10 @@ class TestReconcileRoute(unittest.TestCase):
self.assertEqual(200, status)
self.assertEqual([], payload["reaped"])
def test_non_string_entries_are_rejected(self) -> None:
def test_non_string_entries_are_ignored(self) -> None:
dead = self._old("10.0.0.4")
status, payload = dispatch(
self.orch, "POST", "/reconcile",
_body({"live_source_ips": [None, 7, "10.0.0.9"]}))
self.assertEqual(400, status)
self.assertIn("live_source_ips", str(payload["error"]))
def test_empty_live_source_ip_is_rejected(self) -> None:
status, payload = dispatch(
self.orch, "POST", "/reconcile", _body({"live_source_ips": [""]}))
self.assertEqual(400, status)
self.assertIn("live_source_ips", str(payload["error"]))
def test_invalid_grace_seconds_is_rejected(self) -> None:
for value in (True, "30", -1, float("inf"), float("nan")):
with self.subTest(value=value):
status, payload = dispatch(
self.orch, "POST", "/reconcile",
_body({"live_source_ips": [], "grace_seconds": value}))
self.assertEqual(400, status)
self.assertIn("grace_seconds", str(payload["error"]))
self.assertEqual(200, status)
self.assertEqual([dead], payload["reaped"])

Some files were not shown because too many files have changed in this diff Show More