Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 272e4eb776 |
@@ -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
|
||||
|
||||
@@ -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,27 +0,0 @@
|
||||
name: prd-number-check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
require-numbered-prds:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Reject unnumbered PRDs
|
||||
run: |
|
||||
unnumbered=$(find docs/prds -maxdepth 1 -type f \
|
||||
-name 'prd-new-*.md' -print | sort)
|
||||
|
||||
if [ -n "$unnumbered" ]; then
|
||||
echo "::error::Assign every new PRD its final sequential number before merge."
|
||||
echo "Unnumbered PRDs:"
|
||||
echo "$unnumbered"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All PRDs have final numbers."
|
||||
@@ -0,0 +1,122 @@
|
||||
# Assign sequential numbers to prd-new-*.md files on merge to main.
|
||||
#
|
||||
# When a PR merges to main and includes prd-new-*.md files this workflow:
|
||||
# 1. Finds the next available NNNN number by scanning existing PRDs.
|
||||
# 2. Renames each prd-new-*.md to NNNN-<slug>.md.
|
||||
# 3. Updates the title header (# PRD prd-new: → # PRD NNNN:).
|
||||
# 4. Flips Status: Draft → Active when the push touched files outside
|
||||
# docs/prds/ anywhere in its commit range (i.e. the implementation
|
||||
# shipped together with the PRD).
|
||||
# 5. Commits the renaming back to main.
|
||||
#
|
||||
# No-op if the working tree contains no prd-new-*.md files.
|
||||
#
|
||||
# NOTE: The workflow scans the working tree (not just HEAD~1..HEAD) because
|
||||
# PRs land as multi-commit pushes and the prd-new file is often added in an
|
||||
# earlier commit on the branch, not in the final squash/merge commit.
|
||||
|
||||
name: prd-number
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/prds/prd-new-*.md'
|
||||
|
||||
jobs:
|
||||
assign-numbers:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# No actions/setup-python: the inline script is stdlib-only on the
|
||||
# image's system Python 3.12 (older act_runner mishandles its PATH).
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Assign PRD numbers
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
prds_dir = Path("docs/prds")
|
||||
|
||||
# Scan the working tree — prd-new files may have landed in any
|
||||
# commit of a multi-commit push, not just HEAD.
|
||||
new_prds = sorted(prds_dir.glob("prd-new-*.md"))
|
||||
|
||||
if not new_prds:
|
||||
print("No prd-new-*.md files found — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
# Determine whether non-PRD files were also changed anywhere in
|
||||
# the push range (BEFORE_SHA → HEAD). Falls back to HEAD~1 when
|
||||
# the env var isn't set (e.g. local act runs).
|
||||
before_sha = os.environ.get("GITHUB_EVENT_BEFORE", "HEAD~1")
|
||||
all_changed = subprocess.run(
|
||||
["git", "diff", "--name-only", before_sha, "HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.splitlines()
|
||||
non_prd_changed = any(
|
||||
not f.startswith("docs/prds/") for f in all_changed
|
||||
)
|
||||
|
||||
# Find next available number.
|
||||
existing = sorted(
|
||||
int(m.group(1))
|
||||
for p in prds_dir.glob("*.md")
|
||||
if (m := re.match(r"^(\d{4})-", p.name))
|
||||
)
|
||||
next_num = (max(existing) + 1) if existing else 1
|
||||
|
||||
for prd_path in sorted(new_prds):
|
||||
slug = re.sub(r"^prd-new-", "", prd_path.stem)
|
||||
new_name = f"{next_num:04d}-{slug}.md"
|
||||
new_path = prds_dir / new_name
|
||||
print(f" {prd_path.name} → {new_name}")
|
||||
|
||||
content = prd_path.read_text()
|
||||
|
||||
# Update title header.
|
||||
content = re.sub(
|
||||
r"^(#\s+PRD\s+)prd-new(:)",
|
||||
rf"\g<1>{next_num:04d}\2",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# Conditionally flip Status.
|
||||
if non_prd_changed:
|
||||
content = re.sub(
|
||||
r"(\*\*Status:\*\*\s*)Draft",
|
||||
r"\g<1>Active",
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
new_path.write_text(content)
|
||||
subprocess.run(["git", "rm", str(prd_path)], check=True)
|
||||
subprocess.run(["git", "add", str(new_path)], check=True)
|
||||
next_num += 1
|
||||
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "ci(prd): assign sequential numbers to new PRDs"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "push"], check=True)
|
||||
EOF
|
||||
@@ -1,363 +0,0 @@
|
||||
# Run the complete backend test suite before a release. This workflow is
|
||||
# intentionally manual because Firecracker and macOS use privileged,
|
||||
# self-hosted runners.
|
||||
#
|
||||
# The suite uses stdlib `unittest` discovery — no external Python
|
||||
# dependencies are required to execute it. Tests are split by directory:
|
||||
#
|
||||
# tests/unit/ — pure unit tests; always run
|
||||
# tests/integration/ — need a reachable backend; skip cleanly when
|
||||
# the backend isn't available on the runner
|
||||
# tests/canaries/ — upstream regression canaries; run on a separate
|
||||
# schedule (see canaries.yml), not here
|
||||
#
|
||||
# Unit, Docker, and Firecracker run once under coverage and upload a small
|
||||
# .coverage.* artifact for the combined coverage job. macOS reports coverage
|
||||
# in place because it is an advisory host-mode runner.
|
||||
|
||||
name: pre-release-test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
# lands in one interpreter, `python3` resolves to another). Install
|
||||
# straight into the ephemeral job container's system Python —
|
||||
# --break-system-packages is safe because the container is disposable.
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||
|
||||
- name: Report unit coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# upload-artifact@v3's glob skips dotfiles, so a bare `.coverage.unit`
|
||||
# silently uploads nothing ("No files were found"). Stage it under a
|
||||
# non-dot name; the coverage job renames it back before `coverage
|
||||
# combine`. `cp` also fails loudly if coverage never wrote the file.
|
||||
- name: Stage unit coverage for upload
|
||||
run: cp .coverage.unit coverage-unit.dat
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: coverage-unit.dat
|
||||
|
||||
integration-docker:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: integration-docker-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python (see the note in the `unit` job); the
|
||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job
|
||||
# go green on zero coverage. `backend status` prints a clear per-check
|
||||
# summary (docker on PATH, daemon reachable) and exits non-zero when a
|
||||
# prerequisite is missing — the same readiness check the skip guards
|
||||
# gate on via `has_backend`.
|
||||
- name: Preflight — Docker backend is ready
|
||||
run: |
|
||||
python3 --version
|
||||
python3 cli.py backend status --backend=docker
|
||||
|
||||
- name: Run integration tests (docker) with coverage
|
||||
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
|
||||
|
||||
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
|
||||
- name: Stage docker coverage for upload
|
||||
run: cp .coverage.docker coverage-docker.dat
|
||||
|
||||
- name: Upload docker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-docker
|
||||
path: coverage-docker.dat
|
||||
|
||||
# Integration tests against the Firecracker backend. Runs on a self-hosted
|
||||
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
|
||||
#
|
||||
# Manual only: the privileged KVM runner does not execute proposed changes
|
||||
# unattended.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
||||
# `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
|
||||
# static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a
|
||||
# persistent systemd unit.
|
||||
#
|
||||
# The infra candidate is built here directly (no artifact download) to
|
||||
# eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that
|
||||
# the old build-infra → integration-firecracker + coverage chain incurred.
|
||||
integration-firecracker:
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preflight — Firecracker host is ready
|
||||
run: |
|
||||
command -v firecracker >/dev/null || {
|
||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
||||
# `backend status` exits non-zero unless the TAP pool is up + no
|
||||
# range overlap; it prints the exact `backend setup` fix.
|
||||
python3 cli.py backend status --backend=firecracker
|
||||
|
||||
- name: Build infra candidate from this checkout
|
||||
env:
|
||||
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate --reuse-published
|
||||
|
||||
- name: Replace the persistent infra VM with the candidate
|
||||
run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()'
|
||||
|
||||
# No dev-requirements install: `coverage` is already provided by the
|
||||
# self-hosted runner's Nix python env, and that env has no `pip`
|
||||
# module to install into anyway.
|
||||
- name: Run integration tests (firecracker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: firecracker
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.firecracker
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Stage firecracker coverage for upload
|
||||
run: cp .coverage.firecracker coverage-firecracker.dat
|
||||
|
||||
- name: Upload firecracker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: coverage-firecracker.dat
|
||||
|
||||
- name: Upload tested rootfs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate/
|
||||
|
||||
- name: Upload dropbear for publish verification
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: /var/cache/bot-bottle-fc/dropbear
|
||||
|
||||
# Integration tests against the macOS Apple Container backend. Runs on a
|
||||
# self-hosted macOS runner (label `macos`) registered in HOST mode — Apple
|
||||
# Container needs the host `container` CLI + virtualization framework and
|
||||
# cannot run inside a Linux container, so this cannot reuse the KVM runner.
|
||||
#
|
||||
# Advisory only: workflow_dispatch (manual) exclusively — never push or
|
||||
# pull_request. A single non-redundant laptop that sleeps/roams must not run
|
||||
# unattended on every push to main, let alone block a PR merge, so this job is
|
||||
# deliberately NOT in the `coverage` job's `needs` and its coverage never
|
||||
# feeds the diff-coverage gate. Dispatch-only also means no fork PR (or any
|
||||
# push) ever executes on the host-mode runner.
|
||||
#
|
||||
# The infra container is a singleton (`bot-bottle-mac-infra`); the
|
||||
# `concurrency` group serializes runs so two never collide on it (#425), and
|
||||
# the always-run teardown removes it so a crashed run can't wedge the next.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "macOS Apple Container"):
|
||||
# the `container` CLI on PATH with `container system status` running, and a
|
||||
# Python >=3.11 with `coverage` importable on the launchd service PATH.
|
||||
integration-macos:
|
||||
runs-on: [self-hosted, macos]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
concurrency:
|
||||
group: integration-macos-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job go
|
||||
# green on zero coverage. `backend status` exits non-zero (and prints the
|
||||
# per-check summary) when the `container` CLI or its system service is
|
||||
# missing — the same readiness check the skip guards gate on.
|
||||
- name: Preflight — Apple Container backend is ready
|
||||
run: |
|
||||
command -v container >/dev/null || {
|
||||
echo "container CLI not on PATH — provision the runner (README: macOS Apple Container)"; exit 1; }
|
||||
container system status || {
|
||||
echo "container system service not running — run 'container system start'"; exit 1; }
|
||||
python3 cli.py backend status --backend=macos-container
|
||||
|
||||
# `coverage` comes from the runner's provisioned Python (no pip install
|
||||
# into the host interpreter). Advisory job: report coverage in-line for
|
||||
# visibility but don't upload — it never feeds the combined gate.
|
||||
- name: Run integration tests (macos-container) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: macos-container
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Report macos coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# On failure, capture the infra containers' state and logs BEFORE the
|
||||
# teardown below removes them — otherwise a control-plane crash is
|
||||
# undiagnosable from CI, since `stop()` deletes the orchestrator (and its
|
||||
# logs) on every run. Best-effort: never let the diagnostics themselves
|
||||
# fail the job, and keep going if a container is already gone.
|
||||
- name: Dump infra diagnostics (on failure)
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
echo "=== containers ==="
|
||||
container ls -a | grep bot-bottle-mac || echo "(no bot-bottle-mac containers)"
|
||||
echo "=== networks ==="
|
||||
container network ls | grep bot-bottle-mac || echo "(no bot-bottle-mac networks)"
|
||||
for c in bot-bottle-mac-orchestrator bot-bottle-mac-infra; do
|
||||
echo "=== inspect $c ==="
|
||||
container inspect "$c" || echo "($c not found)"
|
||||
echo "=== logs $c ==="
|
||||
container logs "$c" || echo "($c logs unavailable)"
|
||||
done
|
||||
exit 0
|
||||
|
||||
# Remove the singleton infra container so a crashed or cancelled run
|
||||
# cannot leave `bot-bottle-mac-infra` wedged for the next job.
|
||||
- name: Teardown infra singleton
|
||||
if: always()
|
||||
run: python3 -c 'from bot_bottle.backend.macos_container.infra import MacosInfraService; MacosInfraService().stop()'
|
||||
|
||||
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||
#
|
||||
# Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
|
||||
# relative_files = True (.coveragerc) so they combine cleanly across runners.
|
||||
# Each test job sets COVERAGE_FILE to an absolute path so coverage.py writes
|
||||
# to a known location that upload-artifact can find regardless of runner env.
|
||||
#
|
||||
coverage:
|
||||
needs: [unit, integration-docker, integration-firecracker]
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
- name: Download unit coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
- name: Download docker coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-docker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
- name: Download firecracker coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
# Rename the non-dot upload names back to the .coverage.* files that
|
||||
# `coverage combine` discovers (see the staging steps in each test job).
|
||||
- name: Reassemble coverage data files
|
||||
run: |
|
||||
mv coverage-unit.dat .coverage.unit
|
||||
mv coverage-docker.dat .coverage.docker
|
||||
mv coverage-firecracker.dat .coverage.firecracker
|
||||
|
||||
- name: Combined coverage (unit + integration, incl. firecracker)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
run: |
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
publish-infra:
|
||||
needs:
|
||||
- unit
|
||||
- integration-docker
|
||||
- integration-firecracker
|
||||
- integration-macos
|
||||
- coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the tested revision
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download the tested rootfs
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
|
||||
# publish_infra re-derives the version from the checkout to confirm the
|
||||
# bundle matches before uploading, and the version hashes the dropbear
|
||||
# bytes. Download the same dropbear integration-firecracker used.
|
||||
- name: Download the staged dropbear
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: firecracker-inputs
|
||||
|
||||
- name: Publish the tested candidate
|
||||
env:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
|
||||
@@ -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
|
||||
+256
-157
@@ -1,6 +1,21 @@
|
||||
# Run the automated test gate when package or runtime inputs change on a PR
|
||||
# or on push to main. Privileged self-hosted backends live in the manually
|
||||
# dispatched pre-release-test workflow.
|
||||
# Run the project's test suite when package or runtime inputs change on a PR
|
||||
# or on push to main.
|
||||
#
|
||||
# The suite uses stdlib `unittest` discovery — no external Python
|
||||
# dependencies are required to execute it. Tests are split by directory:
|
||||
#
|
||||
# tests/unit/ — pure unit tests; always run
|
||||
# tests/integration/ — need a reachable backend; skip cleanly when
|
||||
# the backend isn't available on the runner
|
||||
# tests/canaries/ — upstream regression canaries; run on a separate
|
||||
# schedule (see canaries.yml), not here
|
||||
#
|
||||
# Each test job runs once under coverage and uploads a small .coverage.*
|
||||
# artifact. The `coverage` job combines them — no test reruns, no KVM
|
||||
# dependency on that job. For main-branch pushes only, the tested rootfs
|
||||
# and matching dropbear are uploaded so `publish-infra` can publish the
|
||||
# byte-identical artifact that was tested. PRs avoid the ~194 MB rootfs
|
||||
# transfer entirely.
|
||||
|
||||
name: test
|
||||
|
||||
@@ -12,49 +27,32 @@ 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'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
@@ -63,6 +61,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
# lands in one interpreter, `python3` resolves to another). Install
|
||||
# straight into the ephemeral job container's system Python —
|
||||
# --break-system-packages is safe because the container is disposable.
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
@@ -76,6 +79,10 @@ jobs:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# upload-artifact@v3's glob skips dotfiles, so a bare `.coverage.unit`
|
||||
# silently uploads nothing ("No files were found"). Stage it under a
|
||||
# non-dot name; the coverage job renames it back before `coverage
|
||||
# combine`. `cp` also fails loudly if coverage never wrote the file.
|
||||
- name: Stage unit coverage for upload
|
||||
run: cp .coverage.unit coverage-unit.dat
|
||||
|
||||
@@ -87,16 +94,21 @@ jobs:
|
||||
|
||||
integration-docker:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: integration-docker-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python (see the note in the `unit` job); the
|
||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job
|
||||
# go green on zero coverage. `backend status` prints a clear per-check
|
||||
# summary (docker on PATH, daemon reachable) and exits non-zero when a
|
||||
# prerequisite is missing — the same readiness check the skip guards
|
||||
# gate on via `has_backend`.
|
||||
- name: Preflight — Docker backend is ready
|
||||
run: |
|
||||
python3 --version
|
||||
@@ -106,34 +118,9 @@ 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
|
||||
|
||||
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
|
||||
- name: Stage docker coverage for upload
|
||||
run: cp .coverage.docker coverage-docker.dat
|
||||
|
||||
@@ -143,118 +130,190 @@ jobs:
|
||||
name: coverage-docker
|
||||
path: coverage-docker.dat
|
||||
|
||||
image-input-builds:
|
||||
runs-on: ubuntu-latest
|
||||
# Integration tests against the Firecracker backend. Runs on a self-hosted
|
||||
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
|
||||
#
|
||||
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
|
||||
# PRs don't execute untrusted code on the privileged runner.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
||||
# `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
|
||||
# static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a
|
||||
# persistent systemd unit.
|
||||
#
|
||||
# The infra candidate is built here directly (no artifact download) to
|
||||
# eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that
|
||||
# the old build-infra → integration-firecracker + coverage chain incurred.
|
||||
# For main-branch pushes the tested rootfs and matching dropbear are
|
||||
# uploaded so publish-infra can publish the byte-identical artifact; PRs
|
||||
# skip those uploads entirely.
|
||||
integration-firecracker:
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify shared bases cover supported architectures
|
||||
- name: Preflight — Firecracker host is ready
|
||||
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}")
|
||||
'
|
||||
command -v firecracker >/dev/null || {
|
||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
||||
# `backend status` exits non-zero unless the TAP pool is up + no
|
||||
# range overlap; it prints the exact `backend setup` fix.
|
||||
python3 cli.py backend status --backend=firecracker
|
||||
|
||||
- name: Build infra candidate from this checkout
|
||||
env:
|
||||
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate --reuse-published
|
||||
|
||||
- name: Replace the persistent infra VM with the candidate
|
||||
run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()'
|
||||
|
||||
# No dev-requirements install: `coverage` is already provided by the
|
||||
# self-hosted runner's Nix python env, and that env has no `pip`
|
||||
# module to install into anyway.
|
||||
- name: Run integration tests (firecracker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: firecracker
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.firecracker
|
||||
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 firecracker coverage for upload
|
||||
run: cp .coverage.firecracker coverage-firecracker.dat
|
||||
|
||||
- name: Upload firecracker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: coverage-firecracker.dat
|
||||
|
||||
# Only upload the large rootfs artifact on main-branch pushes;
|
||||
# PRs avoid the ~194 MB transfer. publish-infra only runs on main
|
||||
# and downloads these to publish the byte-identical tested rootfs.
|
||||
- name: Upload tested rootfs (main branch only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate/
|
||||
|
||||
- name: Upload dropbear for publish verification (main branch only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: /var/cache/bot-bottle-fc/dropbear
|
||||
|
||||
# Integration tests against the macOS Apple Container backend. Runs on a
|
||||
# self-hosted macOS runner (label `macos`) registered in HOST mode — Apple
|
||||
# Container needs the host `container` CLI + virtualization framework and
|
||||
# cannot run inside a Linux container, so this cannot reuse the KVM runner.
|
||||
#
|
||||
# Advisory only: workflow_dispatch (manual) exclusively — never push or
|
||||
# pull_request. A single non-redundant laptop that sleeps/roams must not run
|
||||
# unattended on every push to main, let alone block a PR merge, so this job is
|
||||
# deliberately NOT in the `coverage` job's `needs` and its coverage never
|
||||
# feeds the diff-coverage gate. Dispatch-only also means no fork PR (or any
|
||||
# push) ever executes on the host-mode runner.
|
||||
#
|
||||
# The infra container is a singleton (`bot-bottle-mac-infra`); the
|
||||
# `concurrency` group serializes runs so two never collide on it (#425), and
|
||||
# the always-run teardown removes it so a crashed run can't wedge the next.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "macOS Apple Container"):
|
||||
# the `container` CLI on PATH with `container system status` running, and a
|
||||
# Python >=3.11 with `coverage` importable on the launchd service PATH.
|
||||
integration-macos:
|
||||
runs-on: [self-hosted, macos]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
concurrency:
|
||||
group: integration-macos-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job go
|
||||
# green on zero coverage. `backend status` exits non-zero (and prints the
|
||||
# per-check summary) when the `container` CLI or its system service is
|
||||
# missing — the same readiness check the skip guards gate on.
|
||||
- name: Preflight — Apple Container backend is ready
|
||||
run: |
|
||||
command -v container >/dev/null || {
|
||||
echo "container CLI not on PATH — provision the runner (README: macOS Apple Container)"; exit 1; }
|
||||
container system status || {
|
||||
echo "container system service not running — run 'container system start'"; exit 1; }
|
||||
python3 cli.py backend status --backend=macos-container
|
||||
|
||||
# `coverage` comes from the runner's provisioned Python (no pip install
|
||||
# into the host interpreter). Advisory job: report coverage in-line for
|
||||
# visibility but don't upload — it never feeds the combined gate.
|
||||
- name: Run integration tests (macos-container) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: macos-container
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Report macos coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# On failure, capture the infra containers' state and logs BEFORE the
|
||||
# teardown below removes them — otherwise a control-plane crash is
|
||||
# undiagnosable from CI, since `stop()` deletes the orchestrator (and its
|
||||
# logs) on every run. Best-effort: never let the diagnostics themselves
|
||||
# fail the job, and keep going if a container is already gone.
|
||||
- name: Dump infra diagnostics (on failure)
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
echo "=== containers ==="
|
||||
container ls -a | grep bot-bottle-mac || echo "(no bot-bottle-mac containers)"
|
||||
echo "=== networks ==="
|
||||
container network ls | grep bot-bottle-mac || echo "(no bot-bottle-mac networks)"
|
||||
for c in bot-bottle-mac-orchestrator bot-bottle-mac-infra; do
|
||||
echo "=== inspect $c ==="
|
||||
container inspect "$c" || echo "($c not found)"
|
||||
echo "=== logs $c ==="
|
||||
container logs "$c" || echo "($c logs unavailable)"
|
||||
done
|
||||
exit 0
|
||||
|
||||
- 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
|
||||
# Remove the singleton infra container so a crashed or cancelled run
|
||||
# cannot leave `bot-bottle-mac-infra` wedged for the next job.
|
||||
- name: Teardown infra singleton
|
||||
if: always()
|
||||
run: python3 -c 'from bot_bottle.backend.macos_container.infra import MacosInfraService; MacosInfraService().stop()'
|
||||
|
||||
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||
#
|
||||
# Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
|
||||
# relative_files = True (.coveragerc) so they combine cleanly across runners.
|
||||
# Each test job sets COVERAGE_FILE to an absolute path so coverage.py writes
|
||||
# to a known location that upload-artifact can find regardless of runner env.
|
||||
#
|
||||
# Restricted to the same events as integration-firecracker: it depends on
|
||||
# that job's coverage artifact and skips for fork PRs alongside it.
|
||||
coverage:
|
||||
needs: [unit, integration-docker]
|
||||
needs: [unit, integration-docker, integration-firecracker]
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -276,15 +335,55 @@ jobs:
|
||||
name: coverage-docker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
- name: Download firecracker coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
# Rename the non-dot upload names back to the .coverage.* files that
|
||||
# `coverage combine` discovers (see the staging steps in each test job).
|
||||
- name: Reassemble coverage data files
|
||||
run: |
|
||||
mv coverage-unit.dat .coverage.unit
|
||||
mv coverage-docker.dat .coverage.docker
|
||||
mv coverage-firecracker.dat .coverage.firecracker
|
||||
|
||||
- name: Combined coverage (unit + docker integration)
|
||||
- name: Combined coverage (unit + integration, incl. firecracker)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
run: |
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
publish-infra:
|
||||
needs: [unit, integration-docker, integration-firecracker, coverage]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout the tested revision
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download the tested rootfs
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
|
||||
# publish_infra re-derives the version from the checkout to confirm the
|
||||
# bundle matches before uploading, and the version hashes the dropbear
|
||||
# bytes. Download the SAME dropbear integration-firecracker used, or
|
||||
# the recheck computes a "<missing>"-dropbear version and rejects the
|
||||
# candidate.
|
||||
- name: Download the staged dropbear (matches build's version)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: firecracker-inputs
|
||||
|
||||
- name: Publish the tested candidate
|
||||
env:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
|
||||
|
||||
@@ -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%"
|
||||
|
||||
|
||||
@@ -44,10 +44,10 @@ backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
||||
|
||||
- Three kinds of doc, each with its own conventions in-folder; see
|
||||
`docs/README.md` for when to write which:
|
||||
- **PRDs** (`docs/prds/`) — one feature per file. A draft may initially
|
||||
use `prd-new-<kebab>.md`, but its author must assign the next
|
||||
sequential number before merge; CI rejects unnumbered PRDs. A
|
||||
`Status:` line tracks lifecycle: Draft → Active (shipped to `main`) →
|
||||
- **PRDs** (`docs/prds/`) — one feature per file. While a PR is open
|
||||
the file is named `prd-new-<kebab>.md`; CI assigns a sequential
|
||||
number on merge to `main` and renames it. A `Status:` line tracks
|
||||
lifecycle: Draft → Active (shipped to `main`) →
|
||||
Superseded/Retargeted. Format in `docs/prds/README.md`.
|
||||
- **Research notes** (`docs/research/`) — opinionated investigations;
|
||||
unnumbered kebab-case, freeform and verdict-first. See
|
||||
|
||||
+6
-21
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# bot-bottle
|
||||
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||
[](https://coverage.readthedocs.io/)
|
||||
[](https://coverage.readthedocs.io/)
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||
|
||||
**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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,34 +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",
|
||||
"--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()}"
|
||||
@@ -187,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.
|
||||
@@ -202,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
|
||||
@@ -297,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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "bot-bottle-claude-image",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.172"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
Generated
-5448
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."))
|
||||
@@ -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)
|
||||
@@ -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 = ""
|
||||
@@ -252,22 +252,18 @@ 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.
|
||||
# AGit creates pull requests by pushing to refs/for/* (with refs/draft/*
|
||||
# and refs/for-review/* aliases). Those server-owned review refs are not
|
||||
# ordinary repository branches and leave the resulting PR without a
|
||||
# branch that the bottle can update later. Require the normal
|
||||
# push-branch-then-open-PR workflow instead. Deletion remains allowed so
|
||||
# operators can clean up refs created before this guard existed.
|
||||
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
|
||||
echo "git-gate: AGit review refs are disabled; push to refs/heads/<branch> and open the pull request from that branch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -113,7 +113,7 @@ _MIGRATIONS = TableMigrations(
|
||||
# egress allowlist / routes / git config selected by source IP. The
|
||||
# multi-tenant gateway resolves it per request via `attribute`.
|
||||
"ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''",
|
||||
# v4 — per-bottle encrypted egress secrets (PRD 0080).
|
||||
# v4 — per-bottle encrypted egress secrets (PRD prd-new-secret-provider).
|
||||
# One row per env-var: key (env-var name) is plaintext for auditing;
|
||||
# value is the encrypted token string. The encryption key (ENV_VAR_SECRET)
|
||||
# lives only in the agent's environment — a row alone cannot recover the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Symmetric encryption for per-bottle egress secrets (PRD 0080).
|
||||
"""Symmetric encryption for per-bottle egress secrets (PRD prd-new-secret-provider).
|
||||
|
||||
Each agent receives a random ENV_VAR_SECRET at startup — passed as an env var,
|
||||
never logged or persisted. The host uses this key to encrypt each egress auth
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,6 @@ picking the right document for what you're capturing.
|
||||
|
||||
| Artifact | For |
|
||||
|---|---|
|
||||
| **Design workflow** (`docs/design-workflow.md`) | How discussion becomes canonical design, how dependencies are recorded, and when implementation may begin. |
|
||||
| **Glossary** (`docs/glossary.md`) | Canonical term definitions — what words mean in this project. |
|
||||
| **PRD** (`docs/prds/`) | A feature: what to build, scope, success criteria. |
|
||||
| **Research note** (`docs/research/`) | A landscape/tradeoff investigation. |
|
||||
|
||||
+42
-45
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
# ADR 0005: Keep tracker metadata on one tracker object
|
||||
# ADR 0005: Keep tracker metadata on issues
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-18
|
||||
- **Deciders:** didericis
|
||||
|
||||
> **Amended 2026-07-26.** A pull request may carry labels directly instead of
|
||||
> linking a tracking issue. When a PR does link an issue, the issue remains the
|
||||
> canonical owner of planning metadata and the reference is validated.
|
||||
|
||||
## Context
|
||||
|
||||
Gitea exposes labels on both issues and pull requests. Applying the same labels
|
||||
@@ -24,29 +20,19 @@ would make the issue history less truthful.
|
||||
|
||||
## Decision
|
||||
|
||||
Issues are the canonical tracker records and own labels when a separate work
|
||||
item exists. Every issue has at least one label. An issue opened or left
|
||||
without labels receives `Status/Needs Triage` automatically until it is
|
||||
classified.
|
||||
Issues are the canonical tracker records and own labels. Every issue has at
|
||||
least one label. An issue opened or left without labels receives
|
||||
`Status/Needs Triage` automatically until it is classified.
|
||||
|
||||
Every new pull request is tracked in exactly one of two mutually exclusive
|
||||
ways:
|
||||
|
||||
1. It deliberately references at least one existing issue in its title or
|
||||
description. Tracker metadata stays on that issue and the PR remains
|
||||
unlabelled.
|
||||
2. It carries at least one label directly when a separate issue would add no
|
||||
useful planning context.
|
||||
|
||||
Issue references use one of these forms:
|
||||
Pull requests carry no labels. Every new PR deliberately references at least
|
||||
one existing issue in its title or description with one of these forms:
|
||||
|
||||
- `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it.
|
||||
- `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it
|
||||
contributes without completing it.
|
||||
|
||||
Gitea Actions enforces the exclusive either/or PR rule, validates any issue
|
||||
references, and repairs the empty issue-label state. Branch protection makes
|
||||
the PR policy check required.
|
||||
Gitea Actions enforces both PR rules as a status check and repairs the empty
|
||||
issue-label state. Branch protection makes the PR policy check required.
|
||||
|
||||
The policy applies from 2026-07-18 onward. Existing issues may be labelled as
|
||||
they are encountered, but closed PRs are grandfathered: no retrospective
|
||||
@@ -54,13 +40,9 @@ issues or PR labels are created solely to make history conform.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Classification, priority, and workflow metadata have one source of truth for
|
||||
each change: the linked issue when one exists, otherwise the PR.
|
||||
- For issue-backed changes, the PR's issue link is the navigation path to its
|
||||
planning metadata.
|
||||
- Classification, priority, and workflow metadata have one source of truth.
|
||||
- A PR's issue link is the navigation path to its planning metadata.
|
||||
- Multi-PR issues do not require copied or synchronized labels.
|
||||
- Small standalone changes do not require a tracking issue created solely to
|
||||
satisfy automation.
|
||||
- `Status/Needs Triage` is an intentional fallback, not a final
|
||||
classification.
|
||||
- Direct issue creation remains convenient; automation repairs a missing label
|
||||
@@ -71,6 +53,5 @@ issues or PR labels are created solely to make history conform.
|
||||
## Links
|
||||
|
||||
- Issue #405.
|
||||
- `.gitea/workflows/tracker-policy-pr.yml`.
|
||||
- `.gitea/workflows/tracker-policy-issues.yml`.
|
||||
- `.gitea/workflows/tracker-policy.yml`.
|
||||
- `scripts/tracker_policy.py`.
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
# Design workflow
|
||||
|
||||
How bot-bottle turns discussion into canonical design and then into
|
||||
implementation without leaving the repository's architecture scattered across
|
||||
issue and review threads.
|
||||
|
||||
The goal is not more documentation. The goal is one discoverable current answer
|
||||
for every load-bearing design question.
|
||||
|
||||
## Sources of truth
|
||||
|
||||
Design artifacts have different jobs:
|
||||
|
||||
| Artifact | Authority |
|
||||
|---|---|
|
||||
| Decision records | Stable system-wide boundaries, policies, and invariants |
|
||||
| PRDs | The current design for a feature |
|
||||
| Research notes | Evidence and tradeoff analysis; informative, not normative |
|
||||
| Issues | Work tracking, open questions, and discussion |
|
||||
| Pull-request comments | Review history; never the final home of a design decision |
|
||||
|
||||
When a discussion changes the design, update the relevant PRD or decision
|
||||
record before treating the discussion as resolved. A comment may explain why a
|
||||
decision changed, but future implementers must not need to reconstruct the
|
||||
decision from a thread.
|
||||
|
||||
Avoid duplicating the same rule in several canonical documents. Prefer one
|
||||
canonical statement and links from dependent documents.
|
||||
|
||||
## Choosing the canonical artifact
|
||||
|
||||
Use a PRD when the decision describes a feature: its behavior, scope, success
|
||||
criteria, trust model, implementation slices, and tests.
|
||||
|
||||
Use a decision record when the choice is broader than one feature or will
|
||||
constrain several future features. Examples include state ownership, credential
|
||||
boundaries, compatibility policy, and what the project does or does not claim
|
||||
as a security guarantee.
|
||||
|
||||
Use a research note when the conclusion depends on comparing external systems,
|
||||
protocols, or approaches. Promote any resulting project decision into a PRD or
|
||||
decision record.
|
||||
|
||||
## From discussion to implementation
|
||||
|
||||
### 1. Open the design discussion
|
||||
|
||||
An issue may start with incomplete requirements. Record:
|
||||
|
||||
- the problem and desired outcome;
|
||||
- known security or compatibility constraints;
|
||||
- the current owner of affected state and credentials;
|
||||
- related PRDs, decisions, issues, and pull requests;
|
||||
- open questions that would materially change the implementation.
|
||||
|
||||
Do not disguise an unresolved trust-boundary or state-ownership decision as an
|
||||
implementation detail.
|
||||
|
||||
### 2. Draft or update the canonical design
|
||||
|
||||
Before substantial implementation, write the feature PRD and update any
|
||||
system-wide decision it changes.
|
||||
|
||||
An active design should make these relationships visible near its top:
|
||||
|
||||
```markdown
|
||||
Status: Draft | Active | Superseded | Retargeted
|
||||
Depends on: #...
|
||||
Supersedes: ...
|
||||
```
|
||||
|
||||
Record dependencies only on the dependent document. Do not maintain reverse
|
||||
`Blocks` lists that can drift as dependent work changes.
|
||||
|
||||
For security-sensitive work, state:
|
||||
|
||||
- the exact guarantee and explicit non-guarantees;
|
||||
- trusted and untrusted components;
|
||||
- who creates each identity or attribution field;
|
||||
- who owns durable state;
|
||||
- failure and recovery behavior;
|
||||
- how the design is tested at its boundaries.
|
||||
|
||||
### 3. Resolve review into the repository
|
||||
|
||||
When review settles a design-changing question:
|
||||
|
||||
1. Update the canonical document in the same pull request.
|
||||
2. Mark conflicting documents Superseded or Retargeted, or update them.
|
||||
3. Add or adjust dependency links.
|
||||
4. Leave a concise resolution comment linking to the canonical change.
|
||||
|
||||
A useful resolution comment is:
|
||||
|
||||
```text
|
||||
Resolution: <what was decided>
|
||||
Canonicalized in: <document/section/commit>
|
||||
Supersedes: <older statement, if any>
|
||||
Follow-up: <remaining implementation or question>
|
||||
```
|
||||
|
||||
The resolution is incomplete until the repository reflects it.
|
||||
|
||||
### 4. Check design readiness
|
||||
|
||||
Implementation may begin when:
|
||||
|
||||
- the PRD's material trust, ownership, and compatibility questions are settled;
|
||||
- dependencies and blockers are explicit;
|
||||
- the design agrees with current architecture and decision records;
|
||||
- superseded documents are marked or updated;
|
||||
- success criteria and boundary tests are concrete;
|
||||
- remaining open questions can be answered during implementation without
|
||||
changing the feature's guarantee or component ownership.
|
||||
|
||||
Small exploratory spikes may happen earlier. A spike proves feasibility; it does
|
||||
not establish a production contract or silently settle the design.
|
||||
|
||||
### 5. Implement in ordered slices
|
||||
|
||||
Prefer small, independently reviewable slices after the parent design is
|
||||
accepted. Record the dependency chain explicitly.
|
||||
|
||||
Parallel work is safe when slices do not compete for the same unsettled
|
||||
interface or ownership boundary. If a foundational change will alter the
|
||||
transport, schema, state owner, or trust domain used by another slice, land the
|
||||
foundation first.
|
||||
|
||||
An implementation pull request should identify:
|
||||
|
||||
- the PRD or decision it implements;
|
||||
- the implementation chunk;
|
||||
- its base and blockers;
|
||||
- any design deviation discovered during implementation.
|
||||
|
||||
If implementation reveals a load-bearing design change, pause that slice and
|
||||
update the canonical design. Do not let the code and review thread become an
|
||||
undocumented replacement for the PRD.
|
||||
|
||||
## Dependency and staleness management
|
||||
|
||||
### Dependency direction
|
||||
|
||||
Write dependencies in terms of contracts, not chronology:
|
||||
|
||||
```text
|
||||
credential provisioning contract
|
||||
-> host-controller authentication
|
||||
-> privileged host operations
|
||||
```
|
||||
|
||||
If only part of a feature is blocked, say so. For example, a manifest parser may
|
||||
proceed while that feature's durable audit-storage chunk waits for the canonical
|
||||
audit schema.
|
||||
|
||||
### Superseding documents
|
||||
|
||||
Do not silently edit history to make an old design appear to have always said
|
||||
the new thing. Preserve the rationale, but make current status unmistakable:
|
||||
|
||||
```markdown
|
||||
Status: Superseded
|
||||
Superseded by: <document>
|
||||
Reason: <one paragraph>
|
||||
```
|
||||
|
||||
If part of a PRD remains valid, mark it Retargeted and identify which scope moved
|
||||
elsewhere.
|
||||
|
||||
Add a short supersession note near the top explaining what changed, why the old
|
||||
design is no longer current, and where the current design lives. For a research
|
||||
note whose original analysis remains useful, preserve that analysis and append
|
||||
a dated addendum with the newer finding instead of rewriting the note as though
|
||||
it had always reached the new conclusion.
|
||||
|
||||
### Architecture sweeps
|
||||
|
||||
After a foundational change, do a targeted architecture sweep before building
|
||||
more features on it:
|
||||
|
||||
1. Identify the concepts the change affects, such as `bot-bottle.db`, host
|
||||
controller, orchestrator, audit ownership, or signing key.
|
||||
2. Search active PRDs, decisions, and open issues for those concepts.
|
||||
3. Update or supersede contradictory statements.
|
||||
4. Refresh dependency links and the current architecture summary.
|
||||
5. Confirm stacked implementation branches still have the correct base.
|
||||
|
||||
This is a milestone activity, not a recurring documentation ceremony.
|
||||
|
||||
## Pull-request checklist
|
||||
|
||||
Use the relevant items in design and implementation pull requests:
|
||||
|
||||
- [ ] The canonical PRD or decision is linked.
|
||||
- [ ] Design-changing review decisions are reflected in-repo.
|
||||
- [ ] Dependencies and blockers are explicit.
|
||||
- [ ] State, credential, and trust ownership agree with current architecture.
|
||||
- [ ] Superseded or retargeted documents are marked.
|
||||
- [ ] Security guarantees and non-guarantees are precise.
|
||||
- [ ] Open questions do not change the promised guarantee or ownership model.
|
||||
- [ ] Implementation deviations updated the canonical design.
|
||||
|
||||
## Lightweight maintenance
|
||||
|
||||
Automation should enforce document shape, not pretend to understand
|
||||
architecture. Useful checks include:
|
||||
|
||||
- active PRDs contain status and dependency metadata;
|
||||
- superseded PRDs link to their replacement;
|
||||
- referenced documents and issues exist;
|
||||
- implementation pull requests identify their PRD and chunk;
|
||||
- document filenames and lifecycle states follow repository conventions.
|
||||
|
||||
Human review remains responsible for detecting conflicting guarantees or
|
||||
ownership claims.
|
||||
|
||||
The durable rule is simple: **discussion discovers the decision; the repository
|
||||
records it; implementation follows it.**
|
||||
@@ -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.
|
||||
@@ -4,11 +4,6 @@
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-26
|
||||
|
||||
> **Superseded.** PRD 0049 removed the active-agents pane and agent-scoped
|
||||
> operator edit verbs when the dashboard was narrowed back to a proposal-only
|
||||
> supervise TUI. A future agent-management surface was deferred rather than
|
||||
> carried forward from this design. The design below is retained as history.
|
||||
|
||||
## Summary
|
||||
|
||||
The dashboard today is proposal-centric: it lists every pending
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-26
|
||||
|
||||
> **Superseded.** PRD 0049 removed start, re-attach, and stop actions from the
|
||||
> dashboard when it became the proposal-only supervise TUI. Bottle lifecycle
|
||||
> remains in the dedicated CLI commands; no dashboard replacement from this
|
||||
> design remains active. The design below is retained as history.
|
||||
|
||||
## Summary
|
||||
|
||||
Today the dashboard is read-only: it surfaces pending proposals
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-26
|
||||
|
||||
> **Superseded.** PRD 0049 removed agent handoff and tmux pane management when
|
||||
> the dashboard was reduced to the proposal-only supervise TUI. The split-pane
|
||||
> interaction described below has no active replacement and is retained only
|
||||
> as design history.
|
||||
|
||||
## Summary
|
||||
|
||||
When the dashboard runs inside tmux, lay it out as the **left
|
||||
|
||||
+5
-8
@@ -7,13 +7,10 @@ document vs. a research note or a decision record).
|
||||
|
||||
## Naming and numbering
|
||||
|
||||
New PRDs may use a `prd-new-<kebab-title>.md` placeholder name while the
|
||||
design is being drafted. Before merge, assign the next sequential number
|
||||
after the highest-numbered PRD on `main`, rename the file to
|
||||
`NNNN-<kebab-title>.md`, and update the title header. CI blocks merging
|
||||
while any `prd-new-*.md` placeholder remains. If concurrent PRs select the
|
||||
same number, the later PR must take the next available number before it
|
||||
merges. Numbers are never reused; gaps are fine.
|
||||
New PRDs use a `prd-new-<kebab-title>.md` placeholder name while the PR
|
||||
is open. On merge to `main` a CI workflow assigns the next sequential
|
||||
number (`0024-…`, `0025-…`), renames the file, and updates the title
|
||||
header. Numbers are never reused; gaps are fine.
|
||||
|
||||
Once numbered, the filename stays fixed for the life of the doc.
|
||||
|
||||
@@ -29,7 +26,7 @@ The `Status:` line near the top tracks the PRD's lifecycle:
|
||||
## Format
|
||||
|
||||
```markdown
|
||||
# PRD prd-new: <short title> ← replace with the final number before merge
|
||||
# PRD prd-new: <short title> ← placeholder; CI fills in the number on merge
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** <who>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# PRD 0080: Encrypted at-rest egress secrets (SecretProvider, interim slice)
|
||||
# PRD prd-new: Encrypted at-rest egress secrets (SecretProvider, interim slice)
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** didericis
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -54,20 +54,18 @@ def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]:
|
||||
pull = event["pull_request"]
|
||||
errors: list[str] = []
|
||||
labels = pull.get("labels") or []
|
||||
numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
|
||||
if labels and numbers:
|
||||
if labels:
|
||||
errors.append(
|
||||
"PR must use exactly one tracking mode: remove PR labels when "
|
||||
"linking an issue, or remove the issue reference when labels "
|
||||
"belong on the PR."
|
||||
"PRs must be unlabeled; put tracker metadata on the linked issue "
|
||||
f"(found: {', '.join(label['name'] for label in labels)})."
|
||||
)
|
||||
|
||||
numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
|
||||
if not numbers:
|
||||
if not labels:
|
||||
errors.append(
|
||||
"PR must either have a label or reference an issue with "
|
||||
"Closes/Fixes/Resolves #N, Part of #N, Related to #N, "
|
||||
"Refs #N, or References #N."
|
||||
)
|
||||
errors.append(
|
||||
"PR must reference an issue with Closes/Fixes/Resolves #N, "
|
||||
"Part of #N, Related to #N, Refs #N, or References #N."
|
||||
)
|
||||
return errors
|
||||
|
||||
real_issues = 0
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user