Compare commits

..

33 Commits

Author SHA1 Message Date
didericis-claude 4cf57f55bb docs(prd): assign PRD number 0082
lint / lint (push) Successful in 3m6s
test / coverage (pull_request) Blocked by required conditions
prd-number-check / require-numbered-prds (pull_request) Successful in 7s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / unit (pull_request) Successful in 48s
test / image-input-builds (pull_request) Successful in 38s
test / integration-docker (pull_request) Has been cancelled
CI (prd-number-check) rejects unnumbered prd-new-*.md on merge to main.
Rename docs/prds/prd-new-trusted-agent-forge-identity.md to
0082-trusted-agent-forge-identity.md (0081 is claimed by #517/#519) and
update the in-repo 'PRD prd-new-trusted-agent-forge-identity' citations to
'PRD 0082'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:07:20 +00:00
didericis-claude 25113fae92 fix(forge): fail closed on aliases colliding on a routable host
Codex review (PR #520, P1): egress_forge_routes deduplicated referenced
forge accounts by hostname and silently dropped every account after the
first. Two aliases with different token_secret on the same host would let
all API calls to that host authenticate as whichever alias won the dedup —
acting as the wrong forge account and breaking the per-alias identity
guarantee.

Fail closed at composition (before the bottle is created): when two
referenced aliases resolve to the same host but disagree on
origin/auth/token_secret, resolve_forge_associations raises ManifestError.
Identical url/auth under two aliases still dedups to one route. The proxy
routes by host, so an ambiguous credential cannot be selected safely.

Regression tests cover both the conflicting-tokens (raise) and
identical-config (single route) cases; forge.py stays at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:06:10 +00:00
didericis-claude a2edaf8694 feat(manifest): trusted agent forge identity and guidance
Implements PRD prd-new-trusted-agent-forge-identity. Moves author identity
and named forge configurations onto the trusted, host-only agent definition,
and lets a bottle repository optionally associate a git-gate repo with one of
the agent's forge aliases.

- Agent-owned identity: new `author` (name/email) and `forge-accounts`
  (alias -> canonical Gitea /api/v1 origin + host `token_secret` ref) on the
  agent manifest. `author` populates the bottle's git user.name/user.email.
- Remove `git-gate.user` from both agents and bottles; fail with a migration
  pointer to `author`. `git-gate` is no longer accepted on an agent.
- Bottle git-gate repos gain optional `forge: <alias>`, resolved against the
  selected agent's forge-accounts at composition (fail-closed on unknown).
- Fail-closed Gitea API URL validation (https, no userinfo/query/fragment,
  /api/v1 base, host lowercased, trailing slash normalized, host dedup).
- Proxy-held credential: synthesize one inspected, token-authenticated egress
  route per referenced forge alias, scoped to the origin + API prefix. The
  token is resolved from the host env at launch into the egress proxy only —
  never the bottle env, prompt, gitconfig, or workspace.
- Generated, non-secret forge workflow guidance appended to the agent prompt
  for associated repos (API base, branch-backed PR flow, AGit-ref prohibition,
  mutation verification); omitted when no repo declares a forge.
- Agents become home-only: cwd `.bot-bottle/agents` no longer contributes,
  overrides, or is selectable; warned-and-ignored like cwd bottles.
- Docs: README examples, PRD 0011 supersession note, manifest schema docstring.
- Tests: new test_forge_identity suite; legacy git-gate.user/cwd tests updated
  to the agent-owned identity model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:06:10 +00:00
didericis-codex 14f19247c0 docs: clarify forge alias identity 2026-07-27 02:06:10 +00:00
didericis-codex 300b878288 docs: split signing from forge identity PRD 2026-07-27 02:06:10 +00:00
didericis-codex cbd92347d3 docs: redesign forge identity trust boundary 2026-07-27 02:06:10 +00:00
didericis-claude 23e794f273 docs: sharpen the attribution guarantee to a byte<->activation-key binding
Revised per PR #480 (#5607 owner clarification + #5608 codex resolution;
#5612 directs the update):

- The audit row no longer implies upstream observation or agent-only
  authorship. Reworded the guarantee: the row cryptographically binds
  commit bytes (control-plane-RECOMPUTED SHA) to access to the activation
  signing key, and binds that key to control-plane-owned activation
  metadata. An agent can sign arbitrary contents but cannot verify as a
  different activation or choose the recorded metadata.
- Control plane accepts gateway-delivered opaque bytes, independently
  recomputes the Git object ID, verifies the embedded signature against
  the activation key, and stamps its own metadata. Trusts no gateway
  SHA/key/verdict/metadata. No upstream fetch.
- Purged overclaims: removed "a compromised gateway cannot fabricate an
  audit binding" (the sidecar holds the signing capability, so it can —
  and that's acceptable under the intended guarantee), plus "accepted
  push" / "introduced upstream" framing.
- Resolved the control-plane-transport open question in-PRD (was left
  open; codex asked to resolve): transport is gateway bytes +
  recompute + verify; mirror-read is no stronger.

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:06:10 +00:00
didericis-claude f31be349ef docs: drop author/committer enforcement; anchor audit in control plane
Revised per PR #480 review (#5590 + didericis-codex review on d8362ec):

- Remove author/committer enforcement entirely (#5590). The gate no
  longer matches identity fields; author/committer are recorded as
  claims in the audit store. Drop the git-gate.signing.enforce knob
  (which also resolves codex issue 1: a knob that weakened the stated
  guarantee). Add a "Deferred: identity enforcement" section noting it
  as a possible future add. Rename PRD/file to "signed commits & audit
  attribution" since identity is no longer guaranteed.
- Fix control-plane vs data-plane verification (codex issue 2, PRD 0070):
  git-gate (data plane) does a synchronous pre-forward SIGNATURE check
  only; the orchestrator/control plane (sole owner of bot-bottle.db)
  independently re-verifies each signature before writing attributed_commit.
  A gateway assertion alone never creates an audit row. New "Trust
  boundary" + "Control-plane verification & recording" sections.
- Reframe the guarantee to signed provenance + host-owned, independently
  verified audit record; ADR 0002 "claimed, not vouched" posture kept.
- attributed_commit now records claimed author/committer columns.

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:06:10 +00:00
didericis-claude 4ba26b8813 docs: narrow PRD to per-bottle signed identity & audit attribution
Revised per PR #480 review (#5518#5556):

- Rename: "forge subroles" → "per-bottle signed identity & audit
  attribution"; rename the file to match.
- Reframe the guarantee as bottle/activation provenance, not
  cryptographically-vouched author identity. Author/committer name/email
  is a claim carried inside the signed object, made trustworthy by a
  git-gate acceptance check + the host record, not by the signature.
- Add the gate-side acceptance check: on push, every newly-introduced
  commit (excluding upstream-reachable history) must verify against the
  activation key AND match git-gate.user in both author and committer
  fields, else the push is rejected. Host verifies the signature before
  recording a SHA as attributed.
- Audit: retain full public key + fingerprint + principal + validity
  interval (not fingerprint-only); state allowed-signers generation.
- Drop from scope: forge subuser accounts, provisioned API tokens/PAT
  minting, forge status/Verified badges -> future "forge actors" PRD.
  This removes the Gitea PAT bootstrap problem entirely.
- Manifest: drop git-forge/forge-accounts; reuse git-gate.user as the
  enforced identity + add opt-in git-gate.signing. Push stays PRD 0048
  deploy keys, unchanged.

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:06:10 +00:00
didericis-claude f600180861 docs: PRD for forge subroles (per-bottle subuser identity & vouched attribution)
Formalizes the design settled in issue #423: one forge subrole identity
per bottled agent (author + forge account + SSH signing key), reused
across all repos/forges. Vouched attribution via sign-at-commit-time in
the git-gate boundary (forwarded ssh-agent; private key never in the
bottle; no SHA divergence). Forge "Verified" badges abandoned in favor
of local git verify-commit plus durable console audit records and
commit-status badges. Reprovision-per-activation credential lifecycle
(0048 discipline), fail-loud teardown, public-key-fingerprint-only audit
trail on bottled_agent.

Successor to PRD 0027 (claimed-not-vouched, ADR 0002) and PRD 0048
(host-side minting lifecycle).

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 02:06:10 +00:00
didericis-claude 74ec9843f0 chore(coverage): relax thresholds to cut low-value churn
prd-number-check / require-numbered-prds (pull_request) Successful in 9s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / unit (pull_request) Successful in 49s
test / image-input-builds (pull_request) Successful in 50s
test / integration-docker (pull_request) Successful in 56s
test / coverage (pull_request) Successful in 15s
test / image-input-builds (push) Successful in 46s
Update Quality Badges / update-badges (push) Successful in 56s
test / coverage (push) Successful in 22s
test / integration-docker (push) Successful in 1m3s
test / unit (push) Successful in 48s
lint / lint (push) Successful in 2m59s
Lower the diff-coverage gate from 90% to 80% and the critical-module
target from 90% to 85%. The 90% diff gate forced back-fill tests on
nearly every changed line; 80% keeps new code honest without the churn.
Global coverage stays informational per ADR 0004 (no new gate added).

Updates scripts/diff_coverage.py, scripts/coverage.sh,
scripts/critical-modules.txt, .gitea/workflows/test.yml, and records the
change as a dated revision in ADR 0004.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:45:08 -04:00
didericis-claude ed9fc76f97 fix(docker): only heal on the ParseAddr poison signature, not any inspect error
prd-number-check / require-numbered-prds (pull_request) Successful in 5s
test / unit (pull_request) Successful in 50s
tracker-policy-pr / check-pr (pull_request) Successful in 6s
test / image-input-builds (pull_request) Successful in 59s
test / integration-docker (pull_request) Successful in 1m6s
test / coverage (pull_request) Successful in 42s
Update Quality Badges / update-badges (push) Successful in 49s
lint / lint (push) Successful in 1m2s
test / coverage (push) Successful in 24s
test / integration-docker (push) Successful in 1m3s
test / unit (push) Successful in 48s
test / image-input-builds (push) Successful in 2m44s
Address review: the self-heal classified every non-absence `network inspect`
failure as a poisoned network and force-removed the shared gateway. But
inspect also fails on transient daemon/API errors, permission failures,
timeouts, or a bad context — destroying a healthy gateway on that guess would
tear the network out from under every live bottle.

Now the destructive path runs only for the known poison signature (docker's
`ParseAddr` error from the malformed `::1/64` IPv6 gateway). The absent case
(`No such network`) still just creates; any other inspect failure raises a
clear GatewayError without mutating shared state.

Adds a regression test asserting a generic inspect error issues neither
`docker rm --force` nor `docker network rm` and surfaces the error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 01:41:09 +00:00
didericis-claude bd8a146a46 fix(docker): heal a poisoned IPv6 gateway network, not just avoid creating one
test / integration-docker (pull_request) Successful in 1m15s
test / image-input-builds (pull_request) Successful in 1m14s
lint / lint (push) Successful in 3m28s
tracker-policy-pr / check-pr (pull_request) Failing after 10m28s
test / unit (pull_request) Failing after 10m39s
prd-number-check / require-numbered-prds (pull_request) Failing after 10m45s
test / coverage (pull_request) Has been skipped
PR #515 stopped bot-bottle from *creating* a gateway network with a
malformed IPv6 subnet (--ipv6=false), but it can't recover a network that
is *already* poisoned. On a daemon that default-enables IPv6, the fixed-name
`bot-bottle-gateway` network gets an `fdd0::/64` subnet whose `::1/64`
gateway trips docker's own netip.ParseAddr, so `docker network inspect`/`ls`
exit non-zero — poisoning every command that reads networks.

Such a network can survive on a shared runner from a pre-fix or concurrent
launch. `_ensure_network` never healed it: its migrate/recreate branch only
ran when `network inspect` *succeeded*, but a poisoned network makes inspect
*fail*, so the code fell through to `network create`, which no-ops on
"already exists" — leaving the poison in place. The next subnet read then
failed with ConsolidatedLaunchError (test_multitenant_isolation), and a bare
`docker network ls` failed too (test_orphan_cleanup).

Fix, two parts:
- gateway.py: when `network inspect` fails for a reason other than "no such
  network", treat the network as poisoned and force-remove + recreate it
  IPv4-only. Absent-vs-poisoned is distinguished by the inspect stderr.
- test.yml: add an integration-docker preflight that drops the leftover
  gateway network (and its container) before the suite, so direct `network
  ls`/`inspect` calls in tests are clean even on a daemon where the in-code
  heal can't run because inspect itself is what's broken.

Adds unit coverage for the poisoned-heal and the absent-create split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 01:23:25 +00:00
didericis-claude 85fb6b0c98 fix(docker): disable IPv6 on the gateway network
test / coverage (push) Blocked by required conditions
test / unit (push) Successful in 55s
lint / lint (push) Successful in 1m5s
test / image-input-builds (push) Successful in 1m8s
test / integration-docker (push) Has been cancelled
Update Quality Badges / update-badges (push) Failing after 10m53s
On a docker daemon that default-enables IPv6 (default-address-pools),
creating the gateway network with only `--subnet` lets the daemon also
attach an fdd0::/64 IPv6 subnet. Its gateway is stored as `::1/64`,
which trips docker's own netip.ParseAddr in `network inspect`/`ls`:

  ParseAddr("fdd0:0:0:6::1/64"): unexpected character, want colon

That poisons every `_network_cidr`/`network ls` read and fails the
docker integration suite intermittently (whichever run the runner's
IPv6 pool index lands on a broken network). bot-bottle attribution
pins IPv4 source IPs and has no IPv6 support, so pass `--ipv6=false`
explicitly at network create to keep the gateway network IPv4-only
regardless of the daemon default.

Note: an already-poisoned runner still needs a one-time
`docker network rm bot-bottle-gateway` (and possibly a daemon
restart) to clear the malformed network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 18:33:48 -04:00
didericis-codex 902286dbc0 fix(build): key nested images by base content
refresh-image-locks / refresh (push) Successful in 36s
lint / lint (push) Successful in 1m2s
test / image-input-builds (push) Successful in 1m6s
test / integration-docker (push) Successful in 1m12s
Update Quality Badges / update-badges (push) Successful in 56s
test / unit (push) Successful in 2m19s
test / coverage (push) Successful in 15s
2026-07-26 17:30:36 -04:00
didericis-codex 73c566f3ff fix(build): close remaining mutable image inputs 2026-07-26 17:30:36 -04:00
didericis-codex 33b7bcd082 build: centralize pinned base image arguments 2026-07-26 17:30:36 -04:00
didericis-codex 9e83ff1992 test: cover image input policy failure paths 2026-07-26 17:30:36 -04:00
didericis-codex 1d10595e5c build: make pinned image inputs portable 2026-07-26 17:30:36 -04:00
didericis-codex 364cad7e56 ci: isolate gateway lock refresh tooling 2026-07-26 17:30:36 -04:00
didericis-codex ff355f81de test: narrow pinned base match for type checking 2026-07-26 17:30:36 -04:00
didericis-codex cf97a49eac ci: pin compatible gateway lock tooling 2026-07-26 17:30:36 -04:00
didericis-codex e607af73ab test: assert reproducible provider image inputs 2026-07-26 17:30:36 -04:00
didericis-codex 998b7c5dd7 ci: make image input refreshes deterministic 2026-07-26 17:30:36 -04:00
didericis-codex 9a4899d4e1 docs: explain image input refreshes 2026-07-26 17:30:36 -04:00
didericis-codex 5083b45f42 ci: reject mutable image build inputs 2026-07-26 17:30:36 -04:00
didericis-codex 24aeba9676 build: freeze operating system package inputs 2026-07-26 17:30:36 -04:00
Gitea Actions ff36b73ff1 build: refresh image input locks 2026-07-26 17:30:36 -04:00
didericis-codex 10d295eaf6 build: pin and verify image inputs 2026-07-26 17:30:36 -04:00
Gitea Actions 69c4c85cd5 build: refresh image input locks 2026-07-26 17:30:36 -04:00
didericis-codex 52e1249c2f ci: keep feature-branch image locks current 2026-07-26 17:30:36 -04:00
didericis-codex bd4f384038 ci: generate refreshed image input locks 2026-07-26 17:30:36 -04:00
didericis-codex 0500e71383 build: declare reproducible image dependencies 2026-07-26 17:30:36 -04:00
119 changed files with 10208 additions and 1598 deletions
+10
View File
@@ -4,6 +4,13 @@ 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"
@@ -13,6 +20,9 @@ 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
+97
View File
@@ -0,0 +1,97 @@
# 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
+130 -2
View File
@@ -22,11 +22,14 @@ on:
- 'scripts/**/*.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:
@@ -43,11 +46,14 @@ on:
- 'scripts/**/*.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*'
- 'image-build-args.json'
- 'pyproject.toml'
- 'requirements-dev.txt'
- 'requirements.gateway.*'
- '.coveragerc'
- '.dockerignore'
- '.gitea/workflows/test.yml'
- '.gitea/workflows/refresh-image-locks.yml'
- '.gitea/workflows/pre-release-test.yml'
jobs:
@@ -96,6 +102,20 @@ jobs:
python3 --version
python3 cli.py backend status --backend=docker
- name: Preflight — clear any leftover poisoned gateway network
run: |
# The gateway network has a fixed name and persists across jobs on
# this shared runner. A pre-fix or concurrent launch can leave it with
# a malformed IPv6 subnet that trips docker's own ParseAddr in
# `network inspect` (see PR #515); the code now self-heals it, but the
# heal can't run if `network inspect` is what's broken on some daemon
# versions. Drop the network here so this run recreates it IPv4-only.
# Remove the attached gateway container first (else `network rm` fails
# on active endpoints); both are recreated by ensure_running. Harmless
# when absent.
docker rm --force bot-bottle-orch-gateway 2>/dev/null || true
docker network rm bot-bottle-gateway 2>/dev/null || true
- name: Run integration tests (docker) with coverage
env:
BOT_BOTTLE_BACKEND: docker
@@ -137,6 +157,114 @@ jobs:
name: coverage-docker
path: coverage-docker.dat
image-input-builds:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Verify shared bases cover supported architectures
run: |
set -euo pipefail
python_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
docker_cli_ref=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["DOCKER_CLI_BASE_IMAGE"])')
test -n "$python_ref"
test -n "$node_ref"
test -n "$docker_cli_ref"
for ref in "$python_ref" "$node_ref" "$docker_cli_ref"; do
docker buildx imagetools inspect --raw "$ref" |
python3 -c '
import json
import sys
manifest = json.load(sys.stdin)
platforms = {
(item["platform"]["os"], item["platform"]["architecture"])
for item in manifest["manifests"]
if item.get("platform", {}).get("os") != "unknown"
}
required = {("linux", "amd64"), ("linux", "arm64")}
missing = required - platforms
if missing:
raise SystemExit(f"base manifest lacks supported platforms: {missing}")
'
done
- name: Verify Codex archives for all supported architectures
run: |
set -euo pipefail
codex_version=$(
sed -n 's/^ARG CODEX_VERSION=//p' bot_bottle/contrib/codex/Dockerfile
)
test -n "$codex_version"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for target in \
aarch64-unknown-linux-musl \
x86_64-unknown-linux-musl
do
asset="codex-package-${target}.tar.gz"
expected=$(
awk -v asset="$asset" '$2 == asset { print $1 }' \
bot_bottle/contrib/codex/codex-package_SHA256SUMS
)
test -n "$expected"
curl -fsSL \
"https://github.com/openai/codex/releases/download/rust-v${codex_version}/${asset}" \
-o "$tmp/$asset"
echo "$expected $tmp/$asset" | sha256sum -c -
tar -tzf "$tmp/$asset" > "$tmp/$asset.contents"
grep -Fx 'bin/codex' "$tmp/$asset.contents"
grep -Fx 'bin/codex-code-mode-host' "$tmp/$asset.contents"
grep -Fx 'codex-package.json' "$tmp/$asset.contents"
done
- name: Build and smoke-test all supported images
run: |
set -euo pipefail
suffix="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-image-inputs}}"
orchestrator="bot-bottle-orchestrator-inputs:${suffix}"
gateway="bot-bottle-gateway-inputs:${suffix}"
orchestrator_fc="bot-bottle-orchestrator-fc-inputs:${suffix}"
claude="bot-bottle-claude-inputs:${suffix}"
codex="bot-bottle-codex-inputs:${suffix}"
pi="bot-bottle-pi-inputs:${suffix}"
python_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
node_base=$(python3 -c \
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
-t "$orchestrator" -f Dockerfile.orchestrator .
orchestrator_id=$(docker image inspect --format '{{.Id}}' "$orchestrator")
case "$orchestrator_id" in sha256:*) ;; *) exit 1 ;; esac
orchestrator_base="bot-bottle-orchestrator-inputs:sha256-${orchestrator_id#sha256:}"
docker image tag "$orchestrator_id" "$orchestrator_base"
test "$(
docker image inspect --format '{{.Id}}' "$orchestrator_base"
)" = "$orchestrator_id"
docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
-t "$gateway" -f Dockerfile.gateway .
docker build \
--build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_base" \
-t "$orchestrator_fc" -f Dockerfile.orchestrator.fc .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$claude" -f bot_bottle/contrib/claude/Dockerfile .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$codex" -f bot_bottle/contrib/codex/Dockerfile .
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
-t "$pi" -f bot_bottle/contrib/pi/Dockerfile .
docker run --rm --entrypoint python3 "$orchestrator" -c \
'import bot_bottle.orchestrator'
docker run --rm --entrypoint mitmdump "$gateway" --version
docker run --rm "$claude" claude --version
docker run --rm "$codex" codex --version
docker run --rm "$pi" pi --version
coverage:
needs: [unit, integration-docker]
timeout-minutes: 15
@@ -170,7 +298,7 @@ jobs:
- name: Combined coverage (unit + docker integration)
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
- name: Diff-coverage gate (changed lines >= 90%)
- name: Diff-coverage gate (changed lines >= 80%)
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
python3 scripts/diff_coverage.py --base origin/main --min 90
python3 scripts/diff_coverage.py --base origin/main --min 80
+21 -6
View File
@@ -36,11 +36,23 @@
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
# 9100 supervise (MCP HTTP)
# Based on `python:3.12-slim` (Debian trixie) rather than the
# Based on an exact `python:3.12.13-slim-trixie` multi-architecture manifest
# 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.
FROM python:3.12-slim
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
# Runtime system deps:
# git supplies the `git daemon` subcommand (no separate package)
@@ -48,16 +60,19 @@ FROM python:3.12-slim
# 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 update \
RUN apt-get -o Acquire::Check-Valid-Until=false 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 pip-install the same pinned
# version. Its CA dir is set explicitly via `--set confdir=` in
# 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
# egress-entrypoint.sh, so it doesn't depend on a `mitmproxy` home user.
RUN pip install --no-cache-dir mitmproxy==11.1.3
COPY requirements.gateway.lock /tmp/requirements.gateway.lock
RUN pip install --no-cache-dir --require-hashes \
-r /tmp/requirements.gateway.lock
# gitleaks (the pre-receive hook's secret scanner). Installed from its
# official release, pinned by version + SHA256 and verified — rather than
+5 -2
View File
@@ -16,9 +16,12 @@
# secret-dense control plane on a minimal dependency surface is the point
# (PRD 0070's "secret concentration").
#
# Shares the trixie `python:3.12-slim` base with the gateway image.
# 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.
FROM python:3.12-slim
ARG PYTHON_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE}
WORKDIR /app
+14 -3
View File
@@ -12,10 +12,21 @@
# 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.
FROM bot-bottle-orchestrator:latest
# 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}
RUN apt-get update \
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 \
&& apt-get install -y --no-install-recommends \
buildah crun netavark aardvark-dns \
&& rm -rf /var/lib/apt/lists/*
+3
View File
@@ -4,5 +4,8 @@
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
+30 -30
View File
@@ -182,7 +182,9 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
## Manifest
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Both bottles and agents are **home-only**: they live under `~/.bot-bottle/bottles/` and `~/.bot-bottle/agents/`. A `<repo>/.bot-bottle/agents/<name>.md` shipped by a workspace is ignored with a warning — since an agent may select a host identity and forge secret, checked-out content must not define one (PRD 0082). Keep repo-specific behavioral instructions in `AGENTS.md` instead.
Identity is **agent-owned**: the author name/email and named forge accounts live on the agent, not under `git-gate` (which now carries only Git transport policy). A bottle repo may optionally name one of the selected agent's forge aliases via `forge:`.
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
@@ -190,37 +192,19 @@ Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle
---
extends: claude # inherit the Claude provider boundary
env:
GIT_AUTHOR_NAME: didericis
git:
user:
name: "Eric Bauerfeld"
email: "eric+claude@dideric.is"
remotes:
gitea.dideric.is:
Name: bot-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
KnownHostKey: ssh-ed25519 AAAA...
egress:
routes:
- host: gitea.dideric.is
inspect:
auth:
scheme: token # Bearer | token
token_ref: BOT_BOTTLE_GITEA_TOKEN
matches: # optional — restrict to specific paths/methods/headers
- paths:
- {type: prefix, value: /api/v1/}
methods: [GET, POST, PATCH, DELETE]
outbound_detectors: [token_patterns, known_secrets]
inbound_detectors: false # disable response scanning for this host
git-gate:
repos:
bot-bottle:
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
key:
provider: gitea
forge_token_env: GITEA_DEPLOY_TOKEN # deploy-key admin (push), PRD 0048
host_key: "ssh-ed25519 AAAA..."
forge: didericis-gitea # ← selects the agent's forge alias
---
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
gitea over SSH for push, token over HTTPS for the API.
The `gitea-dev` bottle. Gitea over SSH for push; the API credential and
workflow guidance come from the agent's `forge: didericis-gitea` association.
````
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
@@ -230,11 +214,27 @@ gitea over SSH for push, token over HTTPS for the API.
bottle: gitea-dev
skills:
- init-prd
author:
name: didericis-claude
email: eric+claude@dideric.is
forge-accounts:
didericis-gitea:
url: https://gitea.dideric.is/api/v1
auth:
type: token
token_secret: GITEA_CLAUDE_TOKEN # host env var; value never enters the bottle
---
You help maintain Gitea-hosted projects.
````
`author` populates the bottle's `git config user.name/user.email`. When a
selected repo names a `forge` alias, bot-bottle resolves the alias's
`token_secret` from the host env into the egress proxy only (never the bottle),
adds a scoped, proxy-authenticated route to the Gitea API origin, and appends
non-secret forge workflow guidance to the agent's system prompt. Neither the
token value nor its `token_secret` name appears in the bottle env or prompt.
**Egress route fields:**
| Field | Required | Description |
+2 -41
View File
@@ -21,7 +21,7 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Generator, Generic, Sequence, TypeVar
from typing import Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider
from ..egress import EgressPlan
@@ -35,9 +35,6 @@ from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
if TYPE_CHECKING:
from .gateway_attach import GatewayAttachResources
class BackendStatus(enum.IntEnum):
"""Return codes for BottleBackend.status(). READY == 0 so callsites
@@ -265,17 +262,6 @@ class Bottle(ABC):
PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
# The backend-specific handle for one running bottle the reconcile attaches to
# the gateway (a run dir for firecracker, a container name for docker/macOS).
# Registry-style callers that don't care about the handle bind it to `Any`
# (`BottleBackend[Any, Any, Any]`).
AttachTargetT = TypeVar("AttachTargetT")
class InfraLaunchError(RuntimeError):
"""A per-host infra (gateway + orchestrator) launch/reconcile step could not
complete. Shared across backends so the base backend can raise + catch one
type; each backend's ``infra_launch`` module re-exports it."""
@dataclass(frozen=True)
@@ -290,7 +276,7 @@ class BottleImages:
sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT, AttachTargetT]):
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls.
Parameterized over the backend's concrete plan + cleanup-plan types
@@ -518,31 +504,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT, AttachTargetT]):
compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata."""
def attach_bottled_agents_to_gateway(self) -> None:
"""Reconcile every running bottle against the freshly-(re)booted gateway
on the cold-boot bring-up path (PRD 0081). The shared flow + fail-hard
policy live in `gateway_attach`; backends override only the three
primitives below (ADR 0006)."""
from .gateway_attach import reconcile_running_bottles
reconcile_running_bottles(self)
@abstractmethod
def _gateway_attach_resources(self) -> "GatewayAttachResources":
"""Gather what every bottle needs to (re)attach to the current gateway
(the CA now). Raises if the gateway isn't reachable (aborts reconcile)."""
@abstractmethod
def _running_bottles(self) -> Sequence[AttachTargetT]:
"""The live bottles as backend handles for `_attach_bottle_to_gateway`.
Raises if the live set can't be determined (fail hard, no partial set)."""
@abstractmethod
def _attach_bottle_to_gateway(
self, bottle: AttachTargetT, resources: "GatewayAttachResources",
) -> None:
"""(Re)attach one bottle to the current gateway (SSH / `exec`+`cp`).
Raises `InfraLaunchError` naming the bottle on failure."""
@classmethod
@abstractmethod
def is_available(cls) -> bool:
+2 -33
View File
@@ -23,7 +23,7 @@ import shutil
import io
from contextlib import contextmanager, redirect_stderr
from pathlib import Path
from typing import TYPE_CHECKING, Generator, Sequence
from typing import Generator, Sequence
from ...supervisor.types import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ...agent_provider import AgentProvisionPlan
@@ -33,7 +33,6 @@ from ...git_gate import GitGatePlan
from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
@@ -41,27 +40,12 @@ from . import resolve_plan as _resolve_plan
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
if TYPE_CHECKING:
from .infra import DockerInfraService
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan", str]):
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
when set to `docker`; retained as a legacy/example backend."""
name = "docker"
def __init__(self, *, infra: "DockerInfraService | None" = None) -> None:
# The infra service whose gateway just cold-booted, passed in on the
# bring-up reconcile path (PRD 0081) so `_gateway_attach_resources`
# reads THAT gateway's CA rather than a fresh default-named service —
# otherwise a non-default instance (an isolated integration test's
# `-itest-` gateway) reads the default `bot-bottle-orch-gateway`, which
# doesn't exist for it (#519 review). None for ordinary construction:
# falls back to the per-host default singleton.
self._infra = infra
@classmethod
def is_available(cls) -> bool:
"""`docker` on PATH is sufficient; we don't probe `docker info`
@@ -156,18 +140,3 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra import DockerInfraService
infra = self._infra if self._infra is not None else DockerInfraService()
return GatewayAttachResources(ca_pem=infra.gateway().ca_cert_pem())
def _running_bottles(self) -> Sequence[str]:
from .infra_launch import running_agent_containers
return running_agent_containers()
def _attach_bottle_to_gateway(
self, bottle: str, resources: GatewayAttachResources,
) -> None:
from .infra_launch import push_ca_to_container
push_ca_to_container(bottle, resources.ca_pem)
@@ -13,7 +13,6 @@ orchestrator-facing wiring so that sequence stays testable in isolation.
from __future__ import annotations
import tempfile
from dataclasses import dataclass
from ... import log
@@ -25,13 +24,15 @@ from ...gateway import GATEWAY_NETWORK
from .infra import INFRA_NAME, DockerInfraService
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.reprovision import reprovision_bottles
from ..base import InfraLaunchError
from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from .gateway_transport import DockerGatewayTransport
from .gateway_net import next_free_ip
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class LaunchContext:
"""What the agent container needs to join the shared gateway."""
@@ -53,7 +54,7 @@ def _network_cidr(network: str) -> str:
])
cidr = proc.stdout.strip()
if proc.returncode != 0 or not cidr:
raise InfraLaunchError(
raise ConsolidatedLaunchError(
f"gateway network {network} has no subnet: {proc.stderr.strip()}"
)
return cidr
@@ -73,59 +74,6 @@ def _network_container_ips(network: str) -> list[str]:
return ips
def running_agent_containers(
network: str = GATEWAY_NETWORK, gateway_name: str = INFRA_NAME,
) -> list[str]:
"""Every running agent container on the gateway `network` (the gateway
itself excluded) the bottles the bring-up reconcile attaches to the fresh
gateway (PRD 0081). Raises `OSError` if `docker` can't be run (fail hard: a
reconcile that can't list its bottles must not look like "no bottles")."""
proc = run_docker([
"docker", "network", "inspect", "--format",
"{{range .Containers}}{{.Name}}\n{{end}}", network,
])
return [
name for name in (line.strip() for line in proc.stdout.splitlines())
if name and name != gateway_name
]
def push_ca_to_container(container: str, ca_pem: str) -> None:
"""(Re)attach one running agent container to the current gateway: replace
its trusted gateway CA with `ca_pem` and rebuild its trust store via
`docker cp` + `docker exec` (PRD 0081). Unconditional install there is one
gateway, so no fingerprint match is needed.
Fail hard: raises `InfraLaunchError` (naming the container) on any failure
the base reconcile aggregates and surfaces it rather than leaving the agent
silently unable to reach the gateway."""
mkdir = run_docker(
["docker", "exec", container, "mkdir", "-p",
"/usr/local/share/ca-certificates"]
)
if mkdir.returncode != 0:
raise InfraLaunchError(
f"CA push to {container} failed (mkdir): {mkdir.stderr.strip()}"
)
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
tmp.write(ca_pem)
tmp.flush()
cp = run_docker(["docker", "cp", tmp.name, f"{container}:{AGENT_CA_PATH}"])
if cp.returncode != 0:
raise InfraLaunchError(
f"CA push to {container} failed (cp): {cp.stderr.strip()}"
)
ex = run_docker(
["docker", "exec", container, "sh", "-c",
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
)
if ex.returncode != 0:
raise InfraLaunchError(
f"CA push to {container} failed (update-ca-certificates): "
f"{ex.stderr.strip()}"
)
def _reprovision_running_bottles(
orchestrator_url: str,
network: str = GATEWAY_NETWORK,
@@ -232,8 +180,6 @@ def deprovision_consolidated(
__all__ = [
"LaunchContext",
"launch_consolidated",
"running_agent_containers",
"push_ca_to_container",
"deprovision_consolidated",
"InfraLaunchError",
"ConsolidatedLaunchError",
]
+49 -9
View File
@@ -96,6 +96,11 @@ 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()}")
@@ -135,12 +140,43 @@ class DockerGateway(Gateway):
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.
# Inspectable but mislabelled: the stale auto-IPAM network created
# by older releases. Replace it below.
stale = True
else:
# inspect failed. Classify by stderr — do NOT assume "not absent"
# implies "poisoned": a transient daemon/API error, permission
# failure, timeout, or bad context also fails here, and destroying
# the shared gateway on that guess would tear the network out from
# under every live bottle.
err = inspected.stderr.lower()
if "no such network" in err or "not found" in err:
# Absent: nothing to replace — create it below.
stale = False
elif "parseaddr" in err:
# Present but poisoned. A daemon that default-enables IPv6
# attaches an fdd0::/64 subnet whose `::1/64` gateway trips
# docker's own netip.ParseAddr in `network inspect`/`ls`, so the
# command exits non-zero with that signature. A fixed release
# never *creates* such a network, but one can survive on a
# shared host from an older or concurrent launch — and
# `--ipv6=false` alone can't heal it, since the create below only
# no-ops on "already exists". Force-replace it so later reads
# (e.g. `_network_cidr` pinning a source IP) stop failing.
stale = True
else:
# Unrecognized failure: no evidence the network is malformed.
# Surface it rather than mutate shared state on a guess.
raise GatewayError(
f"gateway network {self.network} could not be inspected: "
f"{inspected.stderr.strip()}"
)
if stale:
# Migrate the stale/poisoned network. 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:
if removed.returncode != 0 and "no such network" not in removed.stderr.lower():
raise GatewayError(
f"gateway network {self.network} needs explicit subnet "
f"{self._subnet} but could not be replaced: "
@@ -148,6 +184,13 @@ class DockerGateway(Gateway):
)
proc = run_docker([
"docker", "network", "create",
# bot-bottle attribution pins IPv4 source IPs; it has no IPv6
# support. Disable IPv6 explicitly so a daemon that default-enables
# it (default-address-pools) can't attach an fdd0::/64 subnet — a
# malformed `::1/64` gateway address then trips docker's own
# ParseAddr in `network inspect`/`ls`, which poisons every launch
# that reads this network's subnet.
"--ipv6=false",
"--subnet", self._subnet,
"--label", f"{_GATEWAY_SUBNET_LABEL}={self._subnet}",
self.network,
@@ -157,7 +200,7 @@ class DockerGateway(Gateway):
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
)
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
# Bind to this orchestrator (PRD 0070): stash the URL its daemons resolve
# policy against + the pre-minted `gateway` token they present, then bring
# the container up. The gateway never mints, so it holds no signing key —
@@ -184,7 +227,7 @@ class DockerGateway(Gateway):
# just when the container is absent.
self._ensure_network()
if self.is_running() and self._running_image_is_current():
return False
return
# 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.
@@ -228,9 +271,6 @@ class DockerGateway(Gateway):
# pre-connect window (they retry /resolve per request).
if self._control_network:
self._connect_control_network()
# (Re)created a fresh container — signal a cold boot so the caller
# reconciles running bottles against it (PRD 0081).
return True
def _connect_control_network(self) -> None:
"""Ensure the `--internal` control network exists and attach the gateway
+1 -8
View File
@@ -142,16 +142,9 @@ class DockerInfraService(InfraService):
# mints the role-scoped `gateway` JWT here and hands it to the gateway,
# which never sees the key (#469). `connect_to_orchestrator` is
# idempotent.
cold_booted = gateway.connect_to_orchestrator(
gateway.connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token(),
)
# A (re)created gateway container minted/mounted its CA afresh and lost
# every bottle's per-bottle state; reconcile the already-running bottles
# against it (PRD 0081). Skipped when a healthy current gateway was left
# untouched — no cold boot, nothing to reconcile.
if cold_booted:
from .backend import DockerBottleBackend
DockerBottleBackend(infra=self).attach_bottled_agents_to_gateway()
return orchestrator.url()
def stop(self) -> None:
+1 -1
View File
@@ -61,7 +61,7 @@ from .compose import (
)
from .consolidated_compose import consolidated_agent_compose
from ...orchestrator.store.config_store import resolve_teardown_timeout
from .infra_launch import launch_consolidated, deprovision_consolidated
from .consolidated_launch import launch_consolidated, deprovision_consolidated
from .infra import INFRA_NAME
from .gateway import DockerGateway
from ... import resources
@@ -131,6 +131,11 @@ 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(
+59 -1
View File
@@ -10,6 +10,7 @@ import shutil
import subprocess
from typing import Iterator
from ... import resources
from ...log import die, info
from ...util import slugify as _slugify
@@ -119,7 +120,13 @@ 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 = "") -> None:
def build_image(
ref: str,
context: str,
*,
dockerfile: str = "",
build_args: dict[str, str] | None = None,
) -> None:
"""Invokes `docker build` every call. Layer cache makes no-change
rebuilds cheap; running every time means Dockerfile edits land
without manual `docker rmi`.
@@ -139,10 +146,61 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
args.append("--no-cache")
if dockerfile:
args.extend(["-f", dockerfile])
effective_build_args = resources.image_build_args(
dockerfile,
context=context,
) if dockerfile else {}
effective_build_args.update(build_args or {})
for name, value in effective_build_args.items():
args.extend(["--build-arg", f"{name}={value}"])
args.append(context)
subprocess.run(args, check=True)
def image_id(ref: str) -> str:
"""Return the exact content-addressed ID for a local image.
This is used when one locally built image is another Dockerfile's base:
passing the ID prevents a mutable tag from being resolved between builds.
"""
result = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", ref])
image = result.stdout.strip()
if result.returncode != 0 or not image.startswith("sha256:"):
detail = (result.stderr or result.stdout or "").strip()
die(f"could not resolve exact image ID for {ref!r}: {detail or '<no detail>'}")
return image
def pinned_local_image_ref(ref: str) -> str:
"""Give a local image a content-derived tag and verify the tag resolves
back to the same image ID.
BuildKit treats a bare ``sha256:...`` ID in ``FROM`` as a registry
repository name. A tag whose complete suffix is the local image ID remains
resolvable by BuildKit, while the post-tag inspection keeps the handoff
fail-closed.
"""
image = image_id(ref)
digest = image.removeprefix("sha256:")
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
die(f"could not derive a local base tag from invalid image ID {image!r}")
repository = ref.split("@", 1)[0]
last_slash = repository.rfind("/")
last_colon = repository.rfind(":")
if last_colon > last_slash:
repository = repository[:last_colon]
pinned_ref = f"{repository}:sha256-{digest}"
result = run_docker(["docker", "image", "tag", image, pinned_ref])
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
die(f"could not tag exact local image {image}: {detail or '<no detail>'}")
if image_id(pinned_ref) != image:
die(f"content-derived local tag {pinned_ref!r} did not resolve to {image}")
return pinned_ref
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
"""Run `argv` inside a throwaway container of a freshly built agent
image and die loudly if it fails, instead of shipping an image
+1 -15
View File
@@ -20,7 +20,6 @@ from ...git_gate import GitGatePlan
from ...manifest import Manifest
from ...supervisor.plan import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
@@ -32,7 +31,7 @@ from .bottle_plan import FirecrackerBottlePlan
class FirecrackerBottleBackend(
BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan", "Path"]
BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan"]
):
name = "firecracker"
@@ -120,19 +119,6 @@ class FirecrackerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
def _gateway_attach_resources(self) -> GatewayAttachResources:
from .gateway import FirecrackerGateway
return GatewayAttachResources(ca_pem=FirecrackerGateway().ca_cert_pem())
def _running_bottles(self) -> Sequence[Path]:
return _cleanup.live_run_dirs()
def _attach_bottle_to_gateway(
self, bottle: Path, resources: GatewayAttachResources,
) -> None:
from .infra_launch import attach_ca_to_agent_vm
attach_ca_to_agent_vm(bottle, resources.ca_pem)
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
return plan.agent_supervise_url
@@ -40,9 +40,7 @@ from ...orchestrator.lifecycle import (
)
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..base import InfraLaunchError
from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from . import cleanup, util
from .gateway import FirecrackerGateway
from .infra import FirecrackerInfraService
@@ -50,6 +48,10 @@ from .infra import FirecrackerInfraService
_ENV_VAR_SECRET_PATH = "/run/bot-bottle/env-var-secret"
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class LaunchContext:
"""What the Firecracker launch needs from the consolidated sequence."""
@@ -81,48 +83,12 @@ def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> Non
input=secret, capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
raise InfraLaunchError(
raise ConsolidatedLaunchError(
f"failed to persist {ENV_VAR_SECRET_NAME} in agent VM: "
f"{proc.stderr.strip() or '<no stderr>'}"
)
def attach_ca_to_agent_vm(run_dir: Path, ca_pem: str) -> None:
"""(Re)attach one running agent VM (identified by its `run_dir`) to the
current gateway: replace its trusted gateway CA with `ca_pem` and rebuild
its trust store over SSH (PRD 0081). Unconditional install there is one
gateway, so no fingerprint match is needed. Bare-pipe input keeps the cert
off argv.
Fail hard: raises `InfraLaunchError` (naming the bottle) if the run dir is
malformed or the push fails the base reconcile aggregates and surfaces it
rather than leaving the agent silently unable to reach the gateway."""
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
raise InfraLaunchError(
f"{run_dir.name}: cannot resolve guest IP / SSH key to attach it "
f"to the gateway"
)
install = (
"umask 022; mkdir -p /usr/local/share/ca-certificates && "
f"cat > {AGENT_CA_PATH} && chmod 644 {AGENT_CA_PATH} && "
"update-ca-certificates"
)
try:
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [install],
input=ca_pem, capture_output=True, text=True, check=False,
)
except OSError as exc:
raise InfraLaunchError(f"{run_dir.name} ({guest_ip}): CA push failed: {exc}") from exc
if proc.returncode != 0:
raise InfraLaunchError(
f"{run_dir.name} ({guest_ip}): CA push failed: "
f"{proc.stderr.strip() or '<no stderr>'}"
)
def _reprovision_running_bottles(client: OrchestratorClient) -> None:
"""Read keys from live agent VMs and restore the restarted gateway."""
try:
@@ -195,8 +161,7 @@ def deprovision_consolidated(
__all__ = [
"LaunchContext",
"launch_consolidated",
"attach_ca_to_agent_vm",
"deprovision_consolidated",
"InfraLaunchError",
"ConsolidatedLaunchError",
"OrchestratorStartError",
]
+2 -7
View File
@@ -69,17 +69,13 @@ class FirecrackerGateway(Gateway):
self._orchestrator_url = ""
self._gateway_token = ""
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
"""Boot the gateway VM on its link resolving policy against
`orchestrator_url`, then seed `gateway_token` over SSH. The orchestrator's
guest IP is parsed from the URL and passed on the cmdline (`bb_orch=`), so
the baked init stays IP-independent. Boot the orchestrator first — the
gateway daemons reach it at startup. The gateway never mints, so it holds
no signing key, only this token (#469).
Always returns True: the firecracker gateway VM is booted fresh here
(this runs only on the infra cold-boot path), so it is always a cold
boot that minted a new CA — the caller reconciles running bottles."""
no signing key, only this token (#469)."""
self._orchestrator_url = orchestrator_url
self._gateway_token = gateway_token
if not self._orchestrator_url:
@@ -109,7 +105,6 @@ class FirecrackerGateway(Gateway):
"the gateway JWT to the gateway VM (its data plane will not start)",
)
self._vm = vm
return True
def is_running(self) -> bool:
return infra_vm._pidfile_alive(infra_vm._gw_dir())
@@ -19,12 +19,14 @@ 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
@@ -55,6 +57,11 @@ 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]
@@ -147,7 +154,13 @@ 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)
_buildah_build(
key,
ip,
ctx,
tag,
resources.image_build_args(dockerfile),
)
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
_stream_rootfs(key, ip, tag, export_ctr, base)
finally:
@@ -184,15 +197,26 @@ 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) -> None:
def _buildah_build(
private_key: Path,
guest_ip: str,
ctx: str,
tag: str,
build_args: dict[str, 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} -t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
f"buildah build {_BUILD_FLAGS} {arg_flags} "
f"-t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
timeout=_BUILD_TIMEOUT_SECONDS,
)
if rc != 0:
+1 -9
View File
@@ -67,17 +67,9 @@ class FirecrackerInfraService(InfraService):
# holds the signing key) mints the role-scoped `gateway` JWT for the
# gateway, which never sees the key (#469).
orchestrator.ensure_running(startup_timeout=startup_timeout)
cold_booted = self.gateway().connect_to_orchestrator(
self.gateway().connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token())
infra_vm.record_booted_version(want)
# The fresh gateway rootfs minted a new CA and lost every bottle's
# git-gate/token state; reconcile the already-running bottles against
# it so a gateway rebuild doesn't silently break their egress
# (PRD 0081). Cold-boot only — the adopt fast-paths above never reach
# here, so a healthy current gateway is left untouched.
if cold_booted:
from .backend import FirecrackerBottleBackend
FirecrackerBottleBackend().attach_bottled_agents_to_gateway()
return url
def stop(self) -> None:
@@ -49,9 +49,17 @@ _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")
_DOCKERFILES = {
"orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"),
"gateway": ("Dockerfile.gateway",),
_BUILD_INPUTS = {
"orchestrator": (
"image-build-args.json",
"Dockerfile.orchestrator",
"Dockerfile.orchestrator.fc",
),
"gateway": (
"image-build-args.json",
"Dockerfile.gateway",
"requirements.gateway.lock",
),
}
_DEFAULT_BASE = "https://gitea.dideric.is"
@@ -101,7 +109,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 _DOCKERFILES[role]:
for name in _BUILD_INPUTS[role]:
h.update(name.encode())
h.update(b"\0")
h.update((repo_root / name).read_bytes())
+6 -1
View File
@@ -133,10 +133,15 @@ 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")
_ORCHESTRATOR_FC_IMAGE,
root,
dockerfile="Dockerfile.orchestrator.fc",
build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_base},
)
def build_rootfs_dir(role: str) -> Path:
+1 -1
View File
@@ -51,7 +51,7 @@ from .bottle import FirecrackerBottle
from .bottle_plan import FirecrackerBottlePlan
from ...orchestrator.store.config_store import resolve_teardown_timeout
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from .infra_launch import (
from .consolidated_launch import (
launch_consolidated,
persist_env_var_secret,
deprovision_consolidated,
-54
View File
@@ -1,54 +0,0 @@
"""The shared gateway-attach reconcile flow (PRD 0081).
`BottleBackend.attach_bottled_agents_to_gateway()` delegates here so every
backend reconciles running bottles against a freshly-booted gateway *the same
way* the control flow + fail-hard policy live in one place and backends
override only the primitives (see ADR 0006). Kept out of `backend/base.py` so
the backend contract module stays lean (the base.py size guardrail).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from .base import InfraLaunchError
if TYPE_CHECKING:
from .base import BottleBackend
@dataclass(frozen=True)
class GatewayAttachResources:
"""Everything a running bottle needs to (re)attach to the current gateway on
a cold boot (PRD 0081). Gathered once per reconcile and handed to every
bottle. The CA now; egress secrets and git-gate state join as the reconcile
grows (#516)."""
ca_pem: str
def reconcile_running_bottles(backend: "BottleBackend[Any, Any, Any]") -> None:
"""Reconcile every already-running bottle against the freshly-(re)booted
gateway: gather the attach resources once, then attach every live bottle.
Fail hard, never skip: a bottle that silently can't reach the fresh gateway
(its egress just starts failing TLS) is worse than a loud bring-up failure,
so any attach failure aborts bring-up. Every bottle is attempted first and
the failures are raised together, so one bring-up surfaces the full blast
radius rather than one bottle at a time."""
resources = backend._gateway_attach_resources()
failures: list[str] = []
for bottle in backend._running_bottles():
try:
backend._attach_bottle_to_gateway(bottle, resources)
except InfraLaunchError as exc:
failures.append(str(exc))
if failures:
raise InfraLaunchError(
f"could not attach {len(failures)} running bottle(s) to the "
f"freshly-booted gateway:\n " + "\n ".join(failures)
)
__all__ = ["GatewayAttachResources", "reconcile_running_bottles"]
+1 -16
View File
@@ -14,7 +14,6 @@ from ...git_gate import GitGatePlan
from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
@@ -26,7 +25,7 @@ from .bottle_plan import MacosContainerBottlePlan
class MacosContainerBottleBackend(
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan", str]
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
):
"""Apple Container backend. Selected by
`BOT_BOTTLE_BACKEND=macos-container` or
@@ -118,19 +117,5 @@ class MacosContainerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra import MacosInfraService
return GatewayAttachResources(ca_pem=MacosInfraService().ca_cert_pem())
def _running_bottles(self) -> Sequence[str]:
from .infra_launch import running_agent_containers
return running_agent_containers()
def _attach_bottle_to_gateway(
self, bottle: str, resources: GatewayAttachResources,
) -> None:
from .infra_launch import push_ca_to_container
push_ca_to_container(bottle, resources.ca_pem)
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
return plan.agent_supervise_url
@@ -34,7 +34,6 @@ every real command arrives through `container exec`.
from __future__ import annotations
import tempfile
from dataclasses import dataclass
from ...egress import EgressPlan
@@ -43,9 +42,7 @@ from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..base import InfraLaunchError
from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
from .gateway import GATEWAY_NETWORK
@@ -53,6 +50,10 @@ from .gateway_transport import MacosGatewayTransport
from .infra import MacosInfraService, OrchestratorStartError
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class GatewayEndpoint:
"""What the agent `container run` needs to reach the shared gateway.
@@ -97,52 +98,6 @@ def ensure_gateway(
return endpoint
def running_agent_containers() -> list[str]:
"""Every running agent container's name — the bottles the bring-up reconcile
attaches to the fresh gateway (PRD 0081). Raises `EnumerationError` if the
live set can't be determined (fail hard rather than reconcile a partial
set)."""
return [f"{CONTAINER_NAME_PREFIX}{agent.slug}" for agent in enumerate_active()]
def push_ca_to_container(name: str, ca_pem: str) -> None:
"""(Re)attach one running agent container to the current gateway: replace
its trusted gateway CA with `ca_pem` and rebuild its trust store via
`container cp` + `container exec` (PRD 0081). Unconditional install there
is one gateway, so no fingerprint match is needed.
Fail hard: raises `InfraLaunchError` (naming the container) on any failure
the base reconcile aggregates and surfaces it rather than leaving the agent
silently unable to reach the gateway."""
mkdir = container_mod.run_container_argv(
["container", "exec", name, "mkdir", "-p",
"/usr/local/share/ca-certificates"]
)
if mkdir.returncode != 0:
raise InfraLaunchError(
f"CA push to {name} failed (mkdir): {(mkdir.stderr or '').strip()}"
)
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
tmp.write(ca_pem)
tmp.flush()
cp = container_mod.run_container_argv(
["container", "cp", tmp.name, f"{name}:{AGENT_CA_PATH}"]
)
if cp.returncode != 0:
raise InfraLaunchError(
f"CA push to {name} failed (cp): {(cp.stderr or '').strip()}"
)
ex = container_mod.run_container_argv(
["container", "exec", name, "sh", "-c",
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
)
if ex.returncode != 0:
raise InfraLaunchError(
f"CA push to {name} failed (update-ca-certificates): "
f"{(ex.stderr or '').strip()}"
)
def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None:
"""Recover keys from live Apple containers and restore gateway tokens."""
try:
@@ -245,12 +200,10 @@ __all__ = [
"GatewayEndpoint",
"LaunchContext",
"ensure_gateway",
"running_agent_containers",
"push_ca_to_container",
"live_source_ips",
"register_agent",
"deprovision_consolidated",
"InfraLaunchError",
"ConsolidatedLaunchError",
"OrchestratorStartError",
"GATEWAY_NETWORK",
]
@@ -104,15 +104,11 @@ class MacosGateway(Gateway):
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
the agent + control networks, resolving policy against `orchestrator_url`
(the orchestrator's control-network address — Apple has no container
DNS) and presenting `gateway_token`.
Always returns True: this recreates the gateway container unconditionally
(a cold boot), so the caller reconciles running bottles against it
(PRD 0081)."""
DNS) and presenting `gateway_token`."""
self._orchestrator_url = orchestrator_url
self._gateway_token = gateway_token
# Fail closed on a missing policy source or token: the resolver-only data
@@ -160,7 +156,6 @@ class MacosGateway(Gateway):
f"gateway container failed to start: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
return True
def is_running(self) -> bool:
return container_mod.container_is_running(self.name)
+1 -9
View File
@@ -138,15 +138,7 @@ class MacosInfraService(InfraService):
# `gateway` JWT and hands it to the gateway, which never sees the key
# (#469).
url = orchestrator.url()
cold_booted = gateway.connect_to_orchestrator(
url, orchestrator.mint_gateway_token())
# A (re)created gateway container minted/mounted its CA afresh and lost
# every bottle's per-bottle state; reconcile the already-running bottles
# against it (PRD 0081). Skipped when a healthy current gateway was left
# untouched — no cold boot, nothing to reconcile.
if cold_booted:
from .backend import MacosContainerBottleBackend
MacosContainerBottleBackend().attach_bottled_agents_to_gateway()
gateway.connect_to_orchestrator(url, orchestrator.mint_gateway_token())
return url
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
+8 -4
View File
@@ -5,7 +5,7 @@ proxies egress through the one per-host gateway, replacing the per-bottle
companion container removed in #385.
The order differs from docker's, forced by Apple Container 1.0.0 having no
`--ip` (see `infra_launch`): the agent is started *before* it is
`--ip` (see `consolidated_launch`): the agent is started *before* it is
registered, because its DHCP-assigned address the attribution key does not
exist until then.
@@ -64,7 +64,7 @@ from . import nested_containers as nested_containers_mod
from .bottle_plan import MacosContainerBottlePlan
from ...orchestrator.store.config_store import resolve_teardown_timeout
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret
from .infra_launch import (
from .consolidated_launch import (
GatewayEndpoint,
ensure_gateway,
register_agent,
@@ -107,7 +107,8 @@ def _layer_nested_containers(
"""
if not plan.nested_containers:
return agent_image
derived = f"{agent_image}{nested_containers_mod.IMAGE_SUFFIX}"
pinned_base = container_mod.pinned_local_image_ref(agent_image)
derived = f"{pinned_base}{nested_containers_mod.IMAGE_SUFFIX}"
if plan.spec.image_policy == "cached":
if not container_mod.image_exists(derived):
die(
@@ -116,7 +117,10 @@ def _layer_nested_containers(
)
info(f"using cached nested-container image {derived!r}")
return derived
return nested_containers_mod.build_image(agent_image, container_mod.build_image)
return nested_containers_mod.build_image(
pinned_base,
container_mod.build_image,
)
@contextmanager
@@ -55,7 +55,7 @@ _GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
def build_image(
base_image: str,
pinned_base: str,
build: Callable[..., None],
) -> str:
"""Layer the nested-container tooling onto an already-built agent image.
@@ -66,14 +66,15 @@ def build_image(
# TODO(#394): replace this hand-rolled Dockerfile with a docker-layer
# abstraction once that infrastructure exists.
"""
image = f"{base_image}{IMAGE_SUFFIX}"
image = f"{pinned_base}{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(
"FROM docker:28-cli AS docker_cli\n"
f"FROM {base_image}\n"
"ARG DOCKER_CLI_BASE_IMAGE\n"
"FROM ${DOCKER_CLI_BASE_IMAGE} AS docker_cli\n"
f"FROM {pinned_base}\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/"
+49 -1
View File
@@ -13,6 +13,7 @@ import time
from datetime import datetime, timezone
from typing import Iterable
from ... import resources
from ...log import die, info
@@ -60,7 +61,13 @@ def dns_server() -> str:
return _host_ipv4_dns() or _DEFAULT_DNS
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
def build_image(
ref: str,
context: str,
*,
dockerfile: str = "",
build_args: dict[str, str] | None = None,
) -> 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
@@ -83,6 +90,13 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
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)
@@ -668,6 +682,40 @@ 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 -1
View File
@@ -118,7 +118,7 @@ class BottlePreparationPlanner:
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=provision,
egress_plan=prepare_egress(bottle, slug, provision),
egress_plan=prepare_egress(manifest, slug, provision),
git_gate_plan=prepare_git_gate(bottle, slug),
supervise_plan=prepare_supervise(bottle, slug),
)
+1 -1
View File
@@ -2,7 +2,7 @@
Register a bottle with the orchestrator and provision its git-gate state into
the shared gateway (`provision_bottle`), and the inverse teardown
(`deprovision_bottle`). Backend-neutral each backend's infra_launch
(`deprovision_bottle`). Backend-neutral each backend's consolidated_launch
drives these through its own `GatewayTransport` rather than re-implementing
them. The git-gate half of the work lives in `provision_gateway`.
"""
+22 -5
View File
@@ -24,10 +24,11 @@ from ..bottle_state import (
supervise_state_dir,
write_metadata,
)
from ..egress import Egress, EgressPlan
from ..egress import Egress, EgressPlan, egress_forge_routes
from ..git_gate import GitGate, GitGatePlan
from ..log import die
from ..manifest import Manifest, ManifestBottle
from ..manifest.forge import render_forge_guidance
from ..supervisor.plan import SupervisePlan
from ..orchestrator.supervisor import Supervisor
from ..util import slugify
@@ -71,12 +72,21 @@ def write_launch_metadata(
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file.
Returns (agent_dir, prompt_file)."""
Returns (agent_dir, prompt_file).
For repositories associated with a forge, appends generated, non-secret
provider-specific workflow guidance to the prompt (PRD
0082). The guidance carries neither the
token value nor its `token_secret` name."""
agent = manifest.agent
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
prompt_file.write_text(agent.prompt or "")
prompt = agent.prompt or ""
guidance = render_forge_guidance(manifest.forge_associations)
if guidance:
prompt = f"{prompt.rstrip()}\n\n{guidance}" if prompt.strip() else guidance
prompt_file.write_text(prompt)
prompt_file.chmod(0o600)
return agent_dir, prompt_file
@@ -88,11 +98,18 @@ def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
def prepare_egress(
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
manifest: Manifest, slug: str, provision: AgentProvisionPlan,
) -> EgressPlan:
"""Build the egress plan, adding a scoped, proxy-held Gitea API route for
each forge alias referenced by a selected git-gate repo (PRD
0082). The token is resolved from the host
env at launch and never enters the bottle."""
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
forge_routes = egress_forge_routes(manifest.forge_associations)
return Egress().prepare(
manifest.bottle, slug, egress_dir, provision.egress_routes, forge_routes,
)
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
+3 -3
View File
@@ -26,10 +26,10 @@ from .base import ActiveAgent, BackendStatus, BottleBackend
# Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it
# is not None, so test fakes take effect without triggering real imports.
_backends: dict[str, BottleBackend[Any, Any, Any]] | None = None
_backends: dict[str, BottleBackend[Any, Any]] | None = None
def _get_backends() -> dict[str, BottleBackend[Any, Any, Any]]:
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
"""Return the registry of all backend instances, loading lazily on first call."""
global _backends # pylint: disable=global-statement
if _backends is None:
@@ -48,7 +48,7 @@ def get_bottle_backend(
name: str | None = None,
*,
prompt: bool = True,
) -> BottleBackend[Any, Any, Any]:
) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend.
`name` precedence:
+23 -26
View File
@@ -128,7 +128,7 @@ def cmd_start(argv: list[str]) -> int:
if not manifest.all_agent_names:
print(
"bot-bottle: no agents defined. "
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
"Add an agent to ~/.bot-bottle/agents/ to get started.",
file=sys.stderr,
)
return 1
@@ -383,12 +383,9 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
from ...manifest.loader import scan_agent_names
from ...yaml_subset import YamlSubsetError, parse_frontmatter
# Agents are home-only (PRD 0082).
home_agents = scan_agent_names(manifest.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if manifest.cwd_md is not None:
cwd_agents = scan_agent_names(manifest.cwd_md / "agents")
merged = {**home_agents, **cwd_agents}
path = merged.get(agent_name)
path = home_agents.get(agent_name)
if path is None:
return ""
try:
@@ -488,13 +485,19 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
lines.append(" skills:")
for s in agent.skills:
lines.append(f" - {s}")
if not agent.git_user.is_empty():
lines.append(" git-gate:")
lines.append(" user:")
if agent.git_user.name:
lines.append(f" name: {agent.git_user.name}")
if agent.git_user.email:
lines.append(f" email: {agent.git_user.email}")
if agent.author is not None:
lines.append(" author:")
lines.append(f" name: {agent.author.name}")
lines.append(f" email: {agent.author.email}")
if agent.forge_accounts:
lines.append(" forge-accounts:")
for alias, acct in sorted(agent.forge_accounts.items()):
lines.append(f" {alias}:")
lines.append(f" url: {acct.url}")
lines.append(" auth:")
lines.append(f" type: {acct.auth_type}")
# token_secret name is host config; show the name, never a value.
lines.append(f" token_secret: {acct.token_secret}")
bottle = manifest.bottle
lines.append("bottle:")
@@ -510,20 +513,14 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
for k, v in sorted(bottle.env.items()):
lines.append(f" {k}: {v}")
has_git_gate = not bottle.git_user.is_empty() or bottle.git
if has_git_gate:
if bottle.git:
lines.append(" git-gate:")
if not bottle.git_user.is_empty():
lines.append(" user:")
if bottle.git_user.name:
lines.append(f" name: {bottle.git_user.name}")
if bottle.git_user.email:
lines.append(f" email: {bottle.git_user.email}")
if bottle.git:
lines.append(" repos:")
for entry in bottle.git:
lines.append(f" {entry.Name}:")
lines.append(f" url: {entry.Upstream}")
lines.append(" repos:")
for entry in bottle.git:
lines.append(f" {entry.Name}:")
lines.append(f" url: {entry.Upstream}")
if entry.Forge:
lines.append(f" forge: {entry.Forge}")
if bottle.egress.routes:
lines.append(" egress:")
+20 -10
View File
@@ -8,9 +8,17 @@
# 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.
# Current Node LTS; slim variant keeps the image small while still
# providing apt-get for any future additions.
FROM node:22-trixie-slim
# 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
# Install runtime system deps. claude-code shells out to git for several
# features (status checks, commits, PR creation) — without git in the
@@ -20,7 +28,7 @@ FROM node:22-trixie-slim
# 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 update \
RUN apt-get -o Acquire::Check-Valid-Until=false update \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
@@ -35,15 +43,17 @@ RUN apt-get 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 update \
RUN apt-get -o Acquire::Check-Valid-Until=false update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
# 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 \
# 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 \
&& npm cache clean --force
# Git reads both ~/.gitconfig and ~/.config/git/config. Keep its XDG config
+140
View File
@@ -0,0 +1,140 @@
{
"name": "bot-bottle-claude-image",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bot-bottle-claude-image",
"dependencies": {
"@anthropic-ai/claude-code": "2.1.172"
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.172.tgz",
"integrity": "sha512-SfwC+5fQcmNbvvm+1vLiZbfUxt0PQz9lbXapj9+FI+XY/2e+3zgteBM4JFEXBjULZj1DtZXHmKtAROUrMM9GZg==",
"hasInstallScript": true,
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "bin/claude.exe"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-code-darwin-arm64": "2.1.172",
"@anthropic-ai/claude-code-darwin-x64": "2.1.172",
"@anthropic-ai/claude-code-linux-arm64": "2.1.172",
"@anthropic-ai/claude-code-linux-arm64-musl": "2.1.172",
"@anthropic-ai/claude-code-linux-x64": "2.1.172",
"@anthropic-ai/claude-code-linux-x64-musl": "2.1.172",
"@anthropic-ai/claude-code-win32-arm64": "2.1.172",
"@anthropic-ai/claude-code-win32-x64": "2.1.172"
}
},
"node_modules/@anthropic-ai/claude-code-darwin-arm64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.172.tgz",
"integrity": "sha512-pBRgDo8PAgbt2aE4oc6ZrKdOa/Ax36RAduhLCaI8NWD3a0RDb5mETzQciQLwnuenk0bs27vIRh9Yg1jAYG/0+A==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-code-darwin-x64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.172.tgz",
"integrity": "sha512-vSgibgeCyvCFiLJSXu/sgcd3L/tUjSiS/tfS9rJLXUjElw8satMJhA5pqPUiBmKMflOWKMufbZgzXvLx+OhvFw==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-code-linux-arm64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.172.tgz",
"integrity": "sha512-Ql7AbyaXnlA6NwDUaQGO7ZZis21rMjYjbKzpksMCvY35CmsJqanYgbYSn/rE83u/tZpKo+NqOv+bWEDSzsZ02w==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-arm64-musl": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.172.tgz",
"integrity": "sha512-Pa9mGGmp8QCRC2j1cgcWRRkwsK1x4bPsS3CcLRd936Op0k5et62tD3qIYhUPQOIxeuxe9Tt/y7lX1cx7JTDxyA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-x64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.172.tgz",
"integrity": "sha512-RYCY9EHkmtoAlwBKcWRzGIhuus+GM2CIVCfU81cTQAwDcCHunoBeFn3NqAcFV1VKb4dk9TRarAmQcWMKJrpPig==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-x64-musl": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.172.tgz",
"integrity": "sha512-1NflAnV/MqIlD4rzlGDVsJgGK2xJ2ldVd5pkcbu7PsDJBW5dzKYVa4PmD0715K8Yiji8jQovh/nhIo6gYyTfVQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-win32-arm64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.172.tgz",
"integrity": "sha512-Gj8mIbHDDSGWnoriqB1Jt1uH6cvNBFDQBtIYarCcv0fs+QGCEP6qm2GIaKqxAbwkUaLT0sCW/7+ukvkNdSltuQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/claude-code-win32-x64": {
"version": "2.1.172",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.172.tgz",
"integrity": "sha512-OkheFwagiCEKaO2Sb0j3JTO1NrcR3zTlFik/AN+yQPefhUIGP6vHQDbBG5ksuFe+Dyd9s+P95OUh8WDhccb+Ng==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "bot-bottle-claude-image",
"private": true,
"dependencies": {
"@anthropic-ai/claude-code": "2.1.172"
}
}
+40 -8
View File
@@ -3,9 +3,22 @@
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed for that user.
FROM node:22-trixie-slim
ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
RUN apt-get update \
# 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 \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
@@ -19,7 +32,7 @@ RUN apt-get 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 update \
RUN apt-get -o Acquire::Check-Valid-Until=false update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
@@ -30,10 +43,29 @@ WORKDIR /home/node
ENV PATH="/home/node/.local/bin:${PATH}"
# 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
# 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}"
CMD ["codex"]
@@ -0,0 +1,2 @@
54f79a05aba6f9abf8ef988abcae8bf2fcefba20beb549b4ff2b3acdb2cb6f54 codex-package-aarch64-unknown-linux-musl.tar.gz
71a28d362c96ac9829bf8203a2c71be451aeb726adb843167fdaf0eae8fe7dd9 codex-package-x86_64-unknown-linux-musl.tar.gz
+22 -11
View File
@@ -2,9 +2,18 @@
#
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
FROM node:22-trixie-slim
ARG NODE_BASE_IMAGE
FROM ${NODE_BASE_IMAGE}
RUN apt-get update \
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 \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
@@ -15,13 +24,10 @@ RUN apt-get update \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update \
RUN apt-get -o Acquire::Check-Valid-Until=false 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 \
@@ -34,10 +40,15 @@ RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git
USER node
WORKDIR /home/node
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
# 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}"
CMD ["pi"]
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"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"
}
}
+3
View File
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
EGRESS_ROUTES_IN_CONTAINER,
Egress,
egress_agent_env_entries,
egress_forge_routes,
egress_gateway_env_entries,
egress_manifest_routes,
egress_render_routes,
@@ -51,6 +52,7 @@ _LAZY: dict[str, str] = {
"EGRESS_ROUTES_FILENAME": ".service",
"EGRESS_ROUTES_IN_CONTAINER": ".service",
"egress_agent_env_entries": ".service",
"egress_forge_routes": ".service",
"egress_gateway_env_entries": ".service",
"egress_manifest_routes": ".service",
"egress_render_routes": ".service",
@@ -80,6 +82,7 @@ __all__ = [
"Egress",
"EgressPlan",
"EgressRoute",
"egress_forge_routes",
"egress_manifest_routes",
"egress_render_routes",
"egress_resolve_token_values",
+53 -6
View File
@@ -26,7 +26,7 @@ from ..log import die
from .plan import EgressPlan, EgressRoute
if TYPE_CHECKING:
from ..manifest import ManifestBottle
from ..manifest import ManifestBottle, ResolvedForgeAssociation
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -119,15 +119,61 @@ def egress_manifest_routes(
return tuple(out)
def egress_forge_routes(
associations: "tuple[ResolvedForgeAssociation, ...]",
) -> tuple[EgressRoute, ...]:
"""Synthesize one inspected, token-authenticated egress route per distinct
forge alias referenced by a selected git-gate repo (PRD
0082).
The route is scoped to the canonical forge origin (host) and API prefix
(`/api/v1`): the proxy injects the Gitea `token` scheme using the value of
the host env var named by the account's `token_secret`, resolved at launch
from the host environment the token never enters the bottle. Aliases that
canonicalize to the same host share a single route (deduplicated).
Composition (`resolve_forge_associations`) has already rejected referenced
aliases that share a host but disagree on origin/auth/token_secret, so this
host-dedup only ever collapses genuinely identical credentials it never
silently discards a distinct one."""
out: list[EgressRoute] = []
seen_hosts: set[str] = set()
for assoc in associations:
acct = assoc.account
host_key = acct.host.lower()
if host_key in seen_hosts:
continue
seen_hosts.add(host_key)
out.append(EgressRoute(
host=acct.host,
matches=(CoreMatchEntry(
paths=(CorePathMatch(type="prefix", value=acct.api_prefix),),
),),
auth_scheme=acct.auth_type,
token_ref=acct.token_secret,
inspect=True,
))
return tuple(out)
def egress_routes_for_bottle(
bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle)
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
merged = list(_default_provider_on_match(provider_routes)) + [
r for r in manifest if r.host.lower() not in provisioned_hosts
]
# Provider routes (LLM API) default to redact-on-match; forge routes are
# host-injected but keep the default DLP policy. Both take precedence over
# a manifest route to the same host.
reserved_hosts = (
{pr.host.lower() for pr in provider_routes}
| {fr.host.lower() for fr in forge_routes}
)
merged = (
list(_default_provider_on_match(provider_routes))
+ list(forge_routes)
+ [r for r in manifest if r.host.lower() not in reserved_hosts]
)
return _assign_token_slots(merged)
@@ -367,8 +413,9 @@ class Egress:
slug: str,
stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> EgressPlan:
routes = egress_routes_for_bottle(bottle, provider_routes)
routes = egress_routes_for_bottle(bottle, provider_routes, forge_routes)
log = bottle.egress.Log
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
routes_path.write_text(egress_render_routes(routes, log=log))
+2 -8
View File
@@ -122,18 +122,12 @@ class Gateway(abc.ABC):
return
@abc.abstractmethod
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
"""Bind the gateway to this orchestrator and bring it up: store the URL +
the pre-minted `gateway` token as instance state, then (re)start the
gateway unit carrying the mitmproxy CA + that token, resolving policy
against `orchestrator_url`. Idempotent a healthy, current gateway on
the same binding is left alone; a changed binding reconciles it.
Returns True when the gateway was actually (re)brought up (a cold boot,
so its mitmproxy minted a fresh CA and lost its per-bottle state), False
when a healthy current gateway was left untouched. The bring-up flow
uses this as the cold-boot signal that gates the running-bottle
reconcile (`attach_bottled_agents_to_gateway`, PRD 0081)."""
the same binding is left alone; a changed binding reconciles it."""
@abc.abstractmethod
def is_running(self) -> bool:
+32 -10
View File
@@ -1,10 +1,10 @@
"""Manifest dataclasses (PRD 0011 layout).
Reads the per-file manifest tree:
Reads the per-file manifest tree (home-only
PRD 0082):
$HOME/.bot-bottle/bottles/<name>.md one bottle per file
$HOME/.bot-bottle/agents/<name>.md home-resident agents
$CWD/.bot-bottle/agents/<name>.md cwd-supplied agents
$HOME/.bot-bottle/agents/<name>.md agents
Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is
@@ -15,27 +15,38 @@ Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... }
git-gate: # optional (PRD 0047)
user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional
# git-gate-entry keys: url, key, host_key, forge
# `forge`: optional alias into the selected agent's forge-accounts
egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp
supervise: <bool> # optional (default true)
nested_containers: <bool> # optional (default false)
Agent schema (frontmatter):
bottle: <bottle-name> # required
bottle: <bottle-name> # optional
skills: [ <skill-name>, ... ] # optional
git-gate:
user: { name: <str>, email: <str> } # optional; overlays bottle
author: # optional; agent git identity
name: <str> # required when author is present
email: <str> # required when author is present
forge-accounts: # optional; alias -> forge account
<alias>:
url: <https Gitea /api/v1 base>
auth: { type: token, token_secret: <host env var name> }
# Claude Code subagent passthrough fields — accepted, ignored:
name, description, model, color, memory
`author` populates the bottle's git user.name/user.email; `forge-accounts`
maps a forge alias to a Gitea API origin plus a host token reference. Identity
is agent-owned `git-gate` is no longer accepted on an agent (git-gate.user
moved to `author`; git-gate.repos is bottle-only).
The agent file's Markdown body is the system prompt (stripped).
Unknown top-level frontmatter keys raise ManifestError with a hint.
Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
warn at load time and contributes nothing. The trust boundary is
expressed as filesystem layout rather than resolver logic.
Both bottles and agents can ONLY live under $HOME. An agents/ or bottles/
dir under $CWD is a warn at load time and contributes nothing. The trust
boundary is expressed as filesystem layout rather than resolver logic.
Two types are exported:
@@ -66,6 +77,11 @@ if TYPE_CHECKING:
from .agent import ManifestAgent, ManifestAgentProvider
from .bottle import ManifestBottle
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
from .forge import (
ManifestAuthor,
ManifestForgeAccount,
ResolvedForgeAssociation,
)
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
@@ -81,6 +97,9 @@ _LAZY_MODULES: dict[str, str] = {
"EGRESS_AUTH_SCHEMES": "egress",
"ManifestEgressRoute": "egress",
"ManifestEgressConfig": "egress",
"ManifestAuthor": "forge",
"ManifestForgeAccount": "forge",
"ResolvedForgeAssociation": "forge",
"ManifestGitEntry": "git",
"ManifestGitUser": "git",
"ManifestKeyConfig": "git",
@@ -115,4 +134,7 @@ __all__ = [
"EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute",
"ManifestEgressConfig",
"ManifestAuthor",
"ManifestForgeAccount",
"ResolvedForgeAssociation",
]
+44 -24
View File
@@ -3,11 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import cast
from typing import Mapping, cast
from ..agent_provider import PROVIDER_TEMPLATES
from .util import ManifestError, as_json_object
from .git import ManifestGitUser
from .forge import ManifestAuthor, ManifestForgeAccount
from .schema import AGENT_MODEL_KEYS, is_valid_entity_name
@@ -119,15 +119,29 @@ class ManifestAgent:
bottle: str = ""
skills: tuple[str, ...] = ()
prompt: str = ""
# Per-agent git identity (issue #94). Overlays the referenced
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust.
git_user: ManifestGitUser = ManifestGitUser()
# Agent-owned identity (PRD 0082).
# `author` populates the bottle's git user.name/user.email;
# `forge_accounts` maps a forge alias to a canonical Gitea API origin and
# a host token reference. Both live only on the agent — never under
# `git-gate`, which is bottle-only transport policy.
author: ManifestAuthor | None = None
forge_accounts: Mapping[str, ManifestForgeAccount] = field(
default_factory=dict
)
@classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
d = as_json_object(raw, f"agent '{name}'")
# git-gate is no longer accepted on an agent (checked before the
# generic unknown-key error so the migration pointer is surfaced):
# identity moved to `author`, and git-gate.repos is bottle-only.
if "git-gate" in d:
raise ManifestError(
f"agent '{name}' has a 'git-gate' block, which is no longer "
f"accepted on an agent (PRD 0082). "
f"Move git-gate.user name/email into the 'author' block; "
f"git-gate.repos stays on the bottle."
)
unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown:
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
@@ -191,24 +205,30 @@ class ManifestAgent:
f"(was {type(prompt_raw).__name__})"
)
# git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = ManifestGitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
for k in gd:
if k != "user":
raise ManifestError(
f"agent '{name}' git-gate.{k} is not allowed at the "
f"agent level; only git-gate.user (name/email) may be "
f"set on an agent. git-gate.repos is bottle-only "
f"(it carries credentials and host trust)."
)
if "user" in gd:
git_user = ManifestGitUser.from_dict(name, gd["user"])
# author: agent-owned git identity (optional; both fields required
# when present). Populates the bottle's user.name/user.email.
author = (
ManifestAuthor.from_dict(name, d["author"])
if "author" in d else None
)
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
# forge-accounts: alias -> Gitea API origin + host token reference.
forge_accounts: dict[str, ManifestForgeAccount] = {}
forge_raw = d.get("forge-accounts")
if forge_raw is not None:
forge_d = as_json_object(forge_raw, f"agent '{name}' forge-accounts")
for alias, entry in forge_d.items():
forge_accounts[alias] = ManifestForgeAccount.from_dict(
name, alias, entry,
)
return cls(
bottle=bottle,
skills=skills,
prompt=prompt,
author=author,
forge_accounts=forge_accounts,
)
def _parse_provider_settings(
+4 -1
View File
@@ -107,11 +107,14 @@ class ManifestBottle:
)
env[var] = value
# `git_user` is now an internal resolved carrier populated from the
# selected agent's `author` at composition time — it is never parsed
# from the bottle manifest (PRD 0082).
git: tuple[ManifestGitEntry, ...] = ()
git_user = ManifestGitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
git, git_user = parse_git_gate_config(name, git_raw)
git = parse_git_gate_config(name, git_raw)
agent_provider = (
ManifestAgentProvider.from_dict(name, d["agent_provider"])
+1 -1
View File
@@ -210,7 +210,7 @@ def _fold_two_bottles(
for n in names
}
if merged_repos_raw:
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
merged_git = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
else:
merged_git = ()
+312
View File
@@ -0,0 +1,312 @@
"""Agent-owned author identity and forge accounts (PRD 0082).
`ManifestAuthor` is the agent's git author identity (name/email) — it moved off
`git-gate.user` (PRD 0027 / ADR 0002) and onto the trusted agent definition.
`ManifestForgeAccount` maps a **forge alias** to a canonical HTTPS Gitea API
origin plus the *name* of a host env var holding the operator-provided API
token. The token value never lives on the manifest; the host resolves it only
when a selected bottle repository references the alias, and hands it to the
egress proxy never to the bottle.
`ResolvedForgeAssociation` is the composed view: one distinct forge alias that
at least one selected git-gate repository points at, with the repository names
that reference it. The proxy-provisioning and prompt-guidance steps consume it.
"""
from __future__ import annotations
import urllib.parse
from dataclasses import dataclass
from .schema import is_valid_entity_name
from .util import ManifestError, as_json_object
# Only the Gitea `/api/v1` base is supported today. A future provider adds a
# validator + auth scheme + guidance renderer in code rather than accepting a
# repository-supplied prompt or path (PRD non-goal: provider-generic prompts).
GITEA_API_PREFIX = "/api/v1"
# Auth schemes an agent forge account may declare. Gitea uses `token`.
FORGE_AUTH_TYPES = ("token",)
def canonicalize_forge_url(
agent_name: str, alias: str, url: object,
) -> tuple[str, str, str, str]:
"""Validate and canonicalize a forge account's API base URL.
Returns `(canonical, origin, host, api_prefix)` where `origin` is
`https://host[:port]` and `canonical` is `origin + api_prefix`.
Fails closed on: non-string, non-`https`, embedded userinfo, query, or
fragment, a missing host, or any path other than the supported Gitea API
base (`/api/v1`, trailing slash tolerated)."""
label = f"agent '{agent_name}' forge-accounts.{alias}.url"
if not isinstance(url, str) or not url:
raise ManifestError(f"{label} is required (non-empty string)")
try:
parts = urllib.parse.urlsplit(url)
except ValueError as e:
raise ManifestError(f"{label} is not a valid URL ({e}): {url!r}") from e
if parts.scheme != "https":
raise ManifestError(
f"{label} must use https (got scheme {parts.scheme!r} in {url!r})"
)
if parts.username or parts.password:
raise ManifestError(
f"{label} must not embed userinfo (user:pass@); the token is held "
f"host-side via auth.token_secret. Got {url!r}"
)
if parts.query:
raise ManifestError(f"{label} must not contain a query string: {url!r}")
if parts.fragment:
raise ManifestError(f"{label} must not contain a fragment: {url!r}")
host = parts.hostname
if not host:
raise ManifestError(f"{label} must include a hostname: {url!r}")
path = parts.path.rstrip("/")
if path != GITEA_API_PREFIX:
raise ManifestError(
f"{label} path must be the Gitea API base '{GITEA_API_PREFIX}' "
f"(got {parts.path!r} in {url!r}); other providers and API paths "
f"are not yet supported."
)
host = host.lower()
netloc = host if parts.port is None else f"{host}:{parts.port}"
origin = f"https://{netloc}"
return f"{origin}{path}", origin, host, path
@dataclass(frozen=True)
class ManifestAuthor:
"""The agent's git author identity. Both fields are required and
non-empty an author that is declared must fully identify the agent.
Populates `user.name` / `user.email` inside the bottle."""
name: str
email: str
@classmethod
def from_dict(cls, agent_name: str, raw: object) -> "ManifestAuthor":
d = as_json_object(raw, f"agent '{agent_name}' author")
for k in d:
if k not in {"name", "email"}:
raise ManifestError(
f"agent '{agent_name}' author has unknown key {k!r}; "
f"allowed: name, email"
)
name = d.get("name")
if not isinstance(name, str) or not name:
raise ManifestError(
f"agent '{agent_name}' author.name must be a non-empty string"
)
email = d.get("email")
if not isinstance(email, str) or not email:
raise ManifestError(
f"agent '{agent_name}' author.email must be a non-empty string"
)
# Git identity validation: reject values that would break the
# `git config user.email` line or smuggle a second config directive.
if any(c in email for c in ("\n", "\r", " ", "\t")):
raise ManifestError(
f"agent '{agent_name}' author.email must not contain whitespace "
f"or newlines (got {email!r})"
)
if any(c in name for c in ("\n", "\r")):
raise ManifestError(
f"agent '{agent_name}' author.name must not contain newlines "
f"(got {name!r})"
)
return cls(name=name, email=email)
@dataclass(frozen=True)
class ManifestForgeAccount:
"""One forge alias on an agent: a canonical Gitea API origin plus the
host env var name that holds the agent-specific API token. The
authenticated account is whoever owns that token there is no separate
account-name field."""
alias: str
url: str # canonical https base incl. /api/v1, no trailing slash
origin: str # https://host[:port]
host: str # lowercased hostname
api_prefix: str # /api/v1
auth_type: str # "token"
token_secret: str # host env var name holding the API token
@classmethod
def from_dict(
cls, agent_name: str, alias: str, raw: object,
) -> "ManifestForgeAccount":
if not is_valid_entity_name(alias):
raise ManifestError(
f"agent '{agent_name}' forge-accounts key {alias!r} is not a "
f"valid alias; must match [a-z][a-z0-9-]*"
)
label = f"agent '{agent_name}' forge-accounts.{alias}"
d = as_json_object(raw, label)
for k in d:
if k not in {"url", "auth"}:
raise ManifestError(
f"{label} has unknown key {k!r}; allowed: url, auth"
)
canonical, origin, host, api_prefix = canonicalize_forge_url(
agent_name, alias, d.get("url"),
)
if "auth" not in d:
raise ManifestError(f"{label} missing required 'auth' block")
auth = as_json_object(d.get("auth"), f"{label}.auth")
for k in auth:
if k not in {"type", "token_secret"}:
raise ManifestError(
f"{label}.auth has unknown key {k!r}; allowed: type, "
f"token_secret"
)
auth_type = auth.get("type")
if auth_type not in FORGE_AUTH_TYPES:
raise ManifestError(
f"{label}.auth.type must be one of "
f"{', '.join(FORGE_AUTH_TYPES)} (got {auth_type!r})"
)
token_secret = auth.get("token_secret")
if not isinstance(token_secret, str) or not token_secret:
raise ManifestError(
f"{label}.auth.token_secret must be a non-empty string (the "
f"name of a host env var holding the API token)"
)
return cls(
alias=alias,
url=canonical,
origin=origin,
host=host,
api_prefix=api_prefix,
auth_type=str(auth_type),
token_secret=token_secret,
)
@dataclass(frozen=True)
class ResolvedForgeAssociation:
"""A distinct forge alias referenced by one or more selected git-gate
repositories. `repo_names` lists the git-gate repo names pointing at
`account.alias` (sorted, deduplicated)."""
account: ManifestForgeAccount
repo_names: tuple[str, ...]
@property
def alias(self) -> str:
return self.account.alias
def resolve_forge_associations(
agent_name: str,
forge_accounts: "dict[str, ManifestForgeAccount]",
git_entries: "tuple[object, ...]",
) -> tuple[ResolvedForgeAssociation, ...]:
"""Compose the agent's forge accounts with the effective bottle's
git-gate repos. Each git entry that declares a `forge` alias must match a
forge account on the selected agent otherwise fail closed before launch.
Returns one association per distinct referenced alias, carrying the repo
names that reference it. Unreferenced accounts produce nothing (no token
is resolved and no route is created for them)."""
by_alias: dict[str, list[str]] = {}
for entry in git_entries:
alias = getattr(entry, "Forge", "")
if not alias:
continue
if alias not in forge_accounts:
available = ", ".join(sorted(forge_accounts)) or "(none)"
raise ManifestError(
f"git-gate.repos['{getattr(entry, 'Name', '?')}'].forge "
f"references forge alias {alias!r}, which is not defined on "
f"agent '{agent_name}'. Available forge-accounts: {available}"
)
by_alias.setdefault(alias, []).append(getattr(entry, "Name", ""))
associations = tuple(
ResolvedForgeAssociation(
account=forge_accounts[alias],
repo_names=tuple(sorted(set(names))),
)
for alias, names in sorted(by_alias.items())
)
# The egress proxy routes by host, so two referenced aliases that resolve
# to the same host must agree on the credential and target — otherwise the
# generated plan would authenticate every call to that host as whichever
# alias happened to win, silently acting as the wrong forge account. Fail
# closed here (before the bottle is created) rather than dropping a
# credential at route-synthesis time.
by_host: dict[str, ManifestForgeAccount] = {}
for assoc in associations:
acct = assoc.account
prev = by_host.get(acct.host)
if prev is None:
by_host[acct.host] = acct
continue
if (prev.origin, prev.api_prefix, prev.auth_type, prev.token_secret) != (
acct.origin, acct.api_prefix, acct.auth_type, acct.token_secret
):
raise ManifestError(
f"agent '{agent_name}' forge-accounts '{prev.alias}' and "
f"'{acct.alias}' both resolve to host '{acct.host}' but differ "
f"in origin/auth/token_secret. The egress proxy routes by host, "
f"so the intended credential would be ambiguous — every request "
f"to '{acct.host}' would authenticate as one account. Give the "
f"aliases distinct hosts, or make their url/auth identical."
)
return associations
def render_forge_guidance(
associations: tuple[ResolvedForgeAssociation, ...],
) -> str:
"""Render the non-secret, provider-specific forge workflow guidance
appended to the agent's system prompt for associated repositories only.
Derived entirely from validated typed fields never repository-supplied
Markdown. Contains neither the token value nor its `token_secret` name.
Returns "" when there are no associations (no section is generated)."""
if not associations:
return ""
lines: list[str] = [
"## Forge access (managed by bot-bottle)",
"",
"bot-bottle authenticates the forge API requests below for you "
"through the egress proxy. Do not read, print, or manually attach "
"an authorization token — the proxy injects it. Never place a token "
"in a request.",
]
for assoc in associations:
acct = assoc.account
for repo in assoc.repo_names:
lines.extend([
"",
f"### Repository `{repo}` (forge alias `{acct.alias}`)",
f"- Forge API base URL: `{acct.url}`.",
f"- This git-gate repository (`{repo}`) is tied to forge "
f"`{acct.alias}`; use its API for forge actions on this repo.",
f"- Call the API at `{acct.url}` over HTTPS through the proxy. "
"The proxy adds authentication; you never supply a token "
"yourself.",
"- Git pushes still use the git-gate remote, not the API.",
"- To propose changes: push a normal branch "
"(`refs/heads/<branch>`) through the git-gate remote, then open "
"a branch-backed pull request through the API.",
"- Do NOT push AGit review refs (`refs/for/*`, `refs/draft/*`, "
"`refs/for-review/*`); they are prohibited here.",
"- Use the API for reviews and comments, and verify the "
"returned object state before claiming the task is complete.",
])
return "\n".join(lines) + "\n"
+38 -12
View File
@@ -117,6 +117,11 @@ class ManifestGitEntry:
UpstreamHost: str = ""
UpstreamPort: str = ""
UpstreamPath: str = ""
# Optional forge alias (PRD 0082). When
# set, it must match a `forge-accounts` alias on the selected agent; the
# composition enables a scoped proxy-held API credential and forge
# workflow guidance for this repo. Empty = no forge association.
Forge: str = ""
@classmethod
def from_repos_entry(
@@ -139,10 +144,10 @@ class ManifestGitEntry:
label = f"git-gate.repos[{repo_name!r}]"
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
for k in d:
if k not in {"url", "key", "host_key"}:
if k not in {"url", "key", "host_key", "forge"}:
raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, key, host_key"
f"allowed: url, key, host_key, forge"
)
upstream = d.get("url")
if not isinstance(upstream, str) or not upstream:
@@ -150,6 +155,21 @@ class ManifestGitEntry:
f"bottle '{bottle_name}' {label} missing required string field 'url'"
)
forge = d.get("forge", "")
if not isinstance(forge, str):
raise ManifestError(
f"bottle '{bottle_name}' {label} forge must be a string "
f"(was {type(forge).__name__})"
)
if forge and not _GIT_NAME_RE.match(forge):
# forge aliases follow the kebab-case identifier grammar; the
# cross-check against the agent's forge-accounts happens at
# composition time (it needs the resolved agent).
raise ManifestError(
f"bottle '{bottle_name}' {label} forge {forge!r} is not a "
f"valid forge alias; allowed characters: A-Z a-z 0-9 . _ -"
)
if "key" not in d:
raise ManifestError(
f"bottle '{bottle_name}' {label} missing required 'key' block"
@@ -176,6 +196,7 @@ class ManifestGitEntry:
UpstreamHost=host,
UpstreamPort=port,
UpstreamPath=path,
Forge=forge,
)
@@ -286,21 +307,26 @@ class ManifestGitUser:
def parse_git_gate_config(
bottle_name: str,
raw: object,
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
) -> tuple[ManifestGitEntry, ...]:
"""Parse `git-gate` on a bottle. Only `repos` is accepted; `git-gate.user`
moved to the agent's `author` block (PRD 0082)."""
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
if "user" in d:
raise ManifestError(
f"bottle '{bottle_name}' git-gate.user is no longer supported "
f"(PRD 0082). Move name/email into "
f"the selected home agent's 'author' block:\n"
f" author:\n name: <name>\n email: <email>\n"
f"Identity is agent-owned; git-gate now carries only transport "
f"policy (repos)."
)
for k in d:
if k not in {"user", "repos"}:
if k != "repos":
raise ManifestError(
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
f"allowed: user, repos"
f"allowed: repos"
)
git_user = (
ManifestGitUser.from_dict(bottle_name, d["user"])
if "user" in d
else ManifestGitUser()
)
git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos")
if repos_raw is not None:
@@ -311,4 +337,4 @@ def parse_git_gate_config(
)
validate_unique_git_names(bottle_name, git)
return git, git_user
return git
+88 -75
View File
@@ -19,6 +19,7 @@ from .util import ManifestError, as_json_object
from .agent import ManifestAgent
from .bottle import ManifestBottle
from .extends import merge_bottles_runtime, resolve_bottles
from .forge import ResolvedForgeAssociation, resolve_forge_associations
from .git import ManifestGitUser
from .loader import (
check_stale_json,
@@ -37,30 +38,50 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
return as_json_object(value, label)
def _merge_git_user(
agent_user: ManifestGitUser, base_user: ManifestGitUser
) -> ManifestGitUser:
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
if agent_user.is_empty():
return base_user
return ManifestGitUser(
name=agent_user.name or base_user.name,
email=agent_user.email or base_user.email,
def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None:
"""Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/`
are ignored the filesystem layout IS the trust boundary. `kind` is the
subdir name (`bottles`/`agents`); `home_path` is where they belong."""
stale = cwd_dir / kind
if not stale.is_dir():
return
files = sorted(stale.glob("*.md"))
if not files:
return
names = ", ".join(p.name for p in files)
warn(
f"ignoring {kind[:-1]} file(s) under {stale}: {names}. "
f"{kind.capitalize()} can only live under {home_path} "
f"(PRD 0082). Move them or delete."
)
def _manifest_with_merged_git_user(
agent: "ManifestAgent", raw_bottle: "ManifestBottle"
def _compose_manifest(
agent_name: str,
agent: "ManifestAgent",
raw_bottle: "ManifestBottle",
) -> "Manifest":
"""Build the single-value Manifest, overlaying the agent's git-gate.user
onto the bottle (agent wins on non-empty, per-field). Shared by the eager
and lazy load_for_agent paths."""
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = (
raw_bottle if merged == raw_bottle.git_user
else replace(raw_bottle, git_user=merged)
"""Build the single-value Manifest from the selected agent and its
effective bottle (PRD 0082):
- the agent's `author` populates the bottle's git user.name/user.email;
- each git-gate repo's `forge` alias is resolved against the agent's
`forge-accounts` (failing closed on an unknown alias) into the
Manifest's forge associations.
Shared by the eager (from_json_obj) and lazy (from_md_dirs) paths."""
identity = (
ManifestGitUser(name=agent.author.name, email=agent.author.email)
if agent.author is not None else ManifestGitUser()
)
return Manifest(agent=agent, bottle=bottle)
bottle = (
raw_bottle if identity == raw_bottle.git_user
else replace(raw_bottle, git_user=identity)
)
associations = resolve_forge_associations(
agent_name, dict(agent.forge_accounts), bottle.git,
)
return Manifest(agent=agent, bottle=bottle, forge_associations=associations)
def _resolve_effective_bottle_eager(
@@ -121,26 +142,28 @@ def _resolve_effective_bottle_lazy(
class Manifest:
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
`bottle` is the effective bottle with the agent's git-gate.user already
overlaid per-field (agent wins on non-empty). Backends and provisioners
use this directly no agent_name lookup needed."""
`bottle` is the effective bottle with the agent's `author` already
populated into its git identity. `forge_associations` holds the distinct
forge aliases referenced by the effective bottle's git-gate repos, resolved
against the agent's `forge-accounts`. Backends and provisioners use this
directly no agent_name lookup needed."""
agent: ManifestAgent
bottle: ManifestBottle
forge_associations: tuple[ResolvedForgeAssociation, ...] = ()
def git_identity_summary(self) -> str | None:
"""One-line effective git identity with per-field provenance, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agent.git_user # agent's declared git_user (pre-merge)
merged = self.bottle.git_user # effective git_user (post-merge)
if merged.is_empty():
"""One-line effective git identity, e.g.
`name=claude, email=eric@dideric.is`. Sourced from the agent's
`author` block. Returns None when the agent declares no author."""
gu = self.bottle.git_user
if gu.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
if gu.name:
parts.append(f"name={gu.name}")
if gu.email:
parts.append(f"email={gu.email}")
return ", ".join(parts)
@@ -164,15 +187,15 @@ class ManifestIndex:
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
"""Walk the per-file manifest tree and build a ManifestIndex.
Layout (PRD 0011):
Layout:
$HOME/.bot-bottle/bottles/<name>.md bottles (home-only)
$HOME/.bot-bottle/agents/<name>.md home agents
$CWD/.bot-bottle/agents/<name>.md cwd agents
$HOME/.bot-bottle/agents/<name>.md agents (home-only)
Cwd agents merge into the home agents on the same name
(cwd wins). A bottles/ subdir under $CWD is logged as a
warning and ignored the filesystem layout IS the trust
boundary.
Both agents and bottles are home-only
(PRD 0082): a `bottles/` or `agents/`
subdir under $CWD is logged as a warning and ignored the filesystem
layout IS the trust boundary, since an agent may now select a host
identity and forge secret.
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
returns an empty index instead of dying. This is for
@@ -223,17 +246,12 @@ class ManifestIndex:
Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`."""
if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
files = sorted(stale_bottles.glob("*.md"))
if files:
names = ", ".join(p.name for p in files)
warn(
f"ignoring bottle file(s) under "
f"{stale_bottles}: {names}. Bottles can only "
f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete."
)
_warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/")
# Agents became home-only in
# PRD 0082: a cwd agent file that
# once shadowed a home agent could select a host identity/secret,
# so it is now ignored with a migration pointer.
_warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/")
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
@classmethod
@@ -275,13 +293,12 @@ class ManifestIndex:
In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names."""
from_json_obj) it returns the pre-parsed agents' names.
Agents are home-only (PRD 0082): cwd
agent files never contribute names."""
if self.home_md is not None:
home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set()
if self.cwd_md is not None:
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
return sorted(home_names | cwd_names)
return sorted(scan_agent_names(self.home_md / "agents").keys())
return sorted(self.agents.keys())
def load_for_agent(
@@ -326,7 +343,7 @@ class ManifestIndex:
raw_bottle = _resolve_effective_bottle_eager(
agent_name, agent, bottle_names, self.bottles
)
return _manifest_with_merged_git_user(agent, raw_bottle)
return _compose_manifest(agent_name, agent, raw_bottle)
def _load_for_agent_lazy(
self, agent_name: str, bottle_names: tuple[str, ...]
@@ -334,20 +351,17 @@ class ManifestIndex:
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
its bottle chain from disk for the first time here."""
assert self.home_md is not None # guaranteed by load_for_agent dispatch
# Locate the agent file; cwd wins over home on name collision.
# Agents are home-only (PRD 0082):
# a cwd agent file must not select a host identity or forge secret.
home_agents = scan_agent_names(self.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if self.cwd_md is not None:
cwd_agents = scan_agent_names(self.cwd_md / "agents")
merged_agents = {**home_agents, **cwd_agents}
if agent_name not in merged_agents:
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
if agent_name not in home_agents:
available = ", ".join(sorted(home_agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent_path = merged_agents[agent_name]
agent_path = home_agents[agent_name]
try:
fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e:
@@ -374,15 +388,18 @@ class ManifestIndex:
}
if agent_bottle:
agent_dict["bottle"] = agent_bottle
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
# Surface agent-owned identity keys (and any stale git-gate, so
# ManifestAgent.from_dict raises the migration error).
for key in ("author", "forge-accounts", "git-gate"):
if key in fm:
agent_dict[key] = fm[key]
# Pass the effective bottle name as the known-bottles set so agents
# that have bottle: set are validated; agents without bottle: pass {}
# since bottle_names were already resolved above.
known = {effective_bottle_name} if effective_bottle_name else set()
agent = ManifestAgent.from_dict(agent_name, agent_dict, known)
return _manifest_with_merged_git_user(agent, raw_bottle)
return _compose_manifest(agent_name, agent, raw_bottle)
def has_agent(self, name: str) -> bool:
return name in self.agents
@@ -394,13 +411,9 @@ class ManifestIndex:
if self.has_agent(name):
return
if self.home_md is not None:
# Names-only mode: check file existence without parsing.
home_path = self.home_md / "agents" / f"{name}.md"
cwd_path = (
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
# Names-only mode: check home file existence without parsing.
# Agents are home-only; a cwd agent file is never selectable.
if (self.home_md / "agents" / f"{name}.md").is_file():
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError(
+4 -1
View File
@@ -22,7 +22,10 @@ BOTTLE_KEYS = frozenset(
}
)
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
# `author` / `forge-accounts` are agent-owned identity (PRD
# 0082). `git-gate` is no longer accepted on an
# agent: `git-gate.user` moved to `author`, and `git-gate.repos` is bottle-only.
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "author", "forge-accounts"})
# Claude Code subagent fields bot-bottle ignores at launch but does
# not reject. This lets the same file double as
-21
View File
@@ -184,27 +184,6 @@ class OrchestratorClient:
)
return True
def update_agent_secret(
self, bottle_id: str, name: str, value: str, env_var_secret: str,
) -> bool:
"""Update ONE egress token for a running bottle in place
(`POST /bottles/<id>/secret`) the single-secret form of
`reprovision_gateway`, for pushing a freshly-refreshed host credential
into a bottle without a relaunch. Returns True on success, False when the
orchestrator doesn't know the bottle (404)."""
status, _ = self._request(
"POST",
f"/bottles/{bottle_id}/secret",
{"name": name, "value": value, "env_var_secret": env_var_secret},
)
if status == 404:
return False
if not 200 <= status < 300:
raise OrchestratorClientError(
f"update_agent_secret {bottle_id}: HTTP {status}"
)
return True
def teardown_bottle(self, bottle_id: str) -> bool:
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
orchestrator didn't know it (404) — idempotent for cleanup paths."""
-29
View File
@@ -200,35 +200,6 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
return 200, {"reprovisioned": True}
return 404, {"error": "no stored secrets for this bottle"}
if (
method == "POST"
and route.startswith("/bottles/")
and route.endswith("/secret")
):
# Update ONE egress token for a running bottle in place — the
# single-secret form of reprovision, for refreshing a short-lived host
# credential (e.g. the Codex access token) without a relaunch. cli-only
# (not in _GATEWAY_ROUTES): a bottle must never set its own tokens.
bottle_id = route[len("/bottles/") : -len("/secret")]
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
name = data.get("name")
value = data.get("value")
env_var_secret = data.get("env_var_secret")
if (
not isinstance(name, str) or not name
or not isinstance(value, str) or not value
or not isinstance(env_var_secret, str) or not env_var_secret
):
return 400, {
"error": "name, value, and env_var_secret (non-empty strings) are required"
}
if orch.update_agent_secret(bottle_id, name, value, env_var_secret):
return 200, {"updated": True}
return 404, {"error": "no such bottle"}
if method == "DELETE" and route.startswith("/bottles/"):
bottle_id = route[len("/bottles/"):]
if orch.teardown_bottle(bottle_id):
-24
View File
@@ -377,30 +377,6 @@ class OrchestratorCore:
return False
return True
def update_agent_secret(
self, bottle_id: str, name: str, value: str, env_var_secret: str,
) -> bool:
"""Update ONE egress token for a known bottle in place — the
single-secret form of ``reprovision_from_secret``.
Sets the in-memory token AND upserts the single re-encrypted row under
*env_var_secret* (the same key the rest of the rows are encrypted with, so
the whole set stays decryptable by a later ``reprovision_from_secret``),
leaving every other token untouched. Returns False if the bottle is
unknown.
Unlike ``reprovision_from_secret`` (which restores the values captured at
launch), this pushes a *caller-supplied* value used to refresh a
short-lived host credential (e.g. the Codex access token) into a
still-running bottle without a relaunch."""
from .store.secret_store import encrypt_value
if self.registry.get(bottle_id) is None:
return False
self._tokens.setdefault(bottle_id, {})[name] = value
self.registry.store_agent_secret(
bottle_id, name, encrypt_value(env_var_secret, value))
return True
# --- consolidated gateway ----------------------------------------------
def gateway_status(self) -> dict[str, object]:
@@ -366,30 +366,6 @@ class RegistryStore(DbStore):
)
self._chmod()
def store_agent_secret(
self,
bottle_id: str,
key: str,
encrypted_value: str,
secret_type: str = "injected_env_var",
) -> None:
"""Upsert ONE encrypted secret row (env-var name → ciphertext) for
*bottle_id*, leaving the bottle's other secrets untouched — the per-key
counterpart of ``store_agent_secrets``' replace-all. Delete-then-insert
because the table carries no unique constraint to `ON CONFLICT` against."""
with self._connection() as conn:
conn.execute(
"DELETE FROM bottled_agent_secrets "
"WHERE bottled_agent_id = ? AND key = ? AND type = ?",
(bottle_id, key, secret_type),
)
conn.execute(
"INSERT INTO bottled_agent_secrets "
"(bottled_agent_id, key, value, type) VALUES (?, ?, ?, ?)",
(bottle_id, key, encrypted_value, secret_type),
)
self._chmod()
def get_agent_secrets(
self,
bottle_id: str,
+73
View File
@@ -20,7 +20,9 @@ from __future__ import annotations
import fcntl
import hashlib
import json
import os
import re
import shutil
import tempfile
from pathlib import Path
@@ -37,9 +39,11 @@ _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",
)
@@ -47,6 +51,12 @@ 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):
@@ -78,6 +88,69 @@ 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"
+6 -2
View File
@@ -3,6 +3,10 @@
- **Status:** Accepted
- **Date:** 2026-06-25
- **Deciders:** didericis
- **Revised:** 2026-07-27 — thresholds relaxed (critical minimum 90→85%,
diff-coverage gate 90→80%) to cut low-value test churn on changed lines.
The risk-weighting structure and the "global is informational" rule are
unchanged.
## Context
@@ -34,7 +38,7 @@ 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
1. **Critical modules must remain ≥ 85%.** 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`;
@@ -55,7 +59,7 @@ integration** suites, with three rules:
The forward-looking guard is a **diff-coverage gate**
(`scripts/diff_coverage.py`): new/changed executable lines on a branch
must be ≥ 90% covered. This catches regressions where they are
must be ≥ 80% covered. This catches regressions where they are
introduced without forcing a back-fill crusade through legacy glue. The
gate skips lines in omitted files (there is no coverage data for them),
so the omit list cannot launder *new* logic into the dark: anything that
@@ -1,65 +0,0 @@
# ADR 0006: Shared behaviour lives in the base backend
- **Status:** Accepted
- **Date:** 2026-07-27
- **Deciders:** didericis
## Context
`BottleBackend` has three concrete implementations (Firecracker, docker,
macOS-container). Cross-backend operations can be expressed two ways:
1. **Abstract method per backend** — the base declares `@abstractmethod foo()`
and each backend writes its own `foo()` end to end.
2. **Template method** — the base owns the *control flow* of `foo()` as a
concrete method and each backend overrides small **primitives** it calls.
Form 1 gives each backend total freedom, which is exactly the problem: the three
implementations drift. The bring-up reconcile (PRD 0081) first shipped as form 1
and immediately diverged — each backend re-implemented "gather CA → list running
bottles → push to each", and one backend's copy silently skipped failures while
another's aborted, an inconsistency caught only in review (#519). The behaviour
that must be identical (the loop, the error policy, the ordering) was duplicated
in the place least likely to stay in sync.
## Decision
Prefer the template-method form for cross-backend behaviour: **encode shared
control flow and policy in the base backend; backends override primitives, not
control flow.** Lean toward *less* freedom in the concrete backends and *more*
consistency in the base.
Concretely, a cross-backend operation on `BottleBackend` should be a concrete
method that:
- owns the sequencing, iteration, and error policy (e.g. fail-hard vs.
best-effort, fail-fast vs. attempt-all-then-aggregate); and
- delegates only the irreducibly backend-specific steps to `@abstractmethod`
primitives with narrow, well-typed signatures.
The first application is `attach_bottled_agents_to_gateway()` (PRD 0081): the
base gathers resources once, attempts every running bottle, and raises an
aggregate `InfraLaunchError` if any failed; backends supply only
`_gateway_attach_resources()`, `_running_bottles()`, and
`_attach_bottle_to_gateway()`.
A backend-specific `@abstractmethod` with no shared control flow (e.g.
`_launch_impl`, `enumerate_active`) stays as form 1 — this ADR is about
operations whose *shape* should be uniform, not about banning abstract methods.
## Consequences
- The error policy, ordering, and loop for a cross-backend operation have one
source of truth; a new backend cannot forget them or diverge.
- New backends implement smaller, more focused primitives instead of re-deriving
a whole flow.
- The base carries more logic and needs generic-enough primitive signatures
(e.g. a backend-specific attach-target type parameter on `BottleBackend`).
- Genuinely backend-specific one-offs still use a plain abstract method; the
guidance is a default, not an absolute.
## Links
- PRD 0081 (`docs/prds/0081-reprovision-gateway-state-on-bringup.md`).
- Review that prompted this: PR #519.
- `bot_bottle/backend/base.py` (`BottleBackend.attach_bottled_agents_to_gateway`).
-10
View File
@@ -55,13 +55,3 @@ agent has two observable states:
Shorthand for an active (running) Bottled Agent. Used in the supervisor TUI
and discovery layer to mean "a bottle whose agent runtime is currently up."
## Infra
The per-host pair of shared services every bottle attaches to: the
**orchestrator** (control plane — holds the signing key, owns the registry DB,
mints tokens) and the **gateway** (data plane — TLS-inspecting egress proxy,
git-gate, supervise). Brought up as an idempotent singleton pair by each
backend's `InfraService` (two containers on docker/macOS, two microVMs on
Firecracker). The per-backend `infra_launch` module composes the bring-up and
bottle register/provision sequence against this pair.
+68
View File
@@ -0,0 +1,68 @@
# 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.
+11 -1
View File
@@ -1,9 +1,19 @@
# PRD 0011: Per-file Markdown manifest
- **Status:** Active
- **Status:** Active (agent cwd-discovery superseded)
- **Author:** didericis
- **Created:** 2026-05-24
> **Superseded in part by PRD 0082.**
> The `$CWD/.bot-bottle/agents/<name>.md` discovery/override path described
> below is removed: agents are now **home-only**, like bottles. Once an agent
> definition can select a host identity (`author`) and a host forge secret
> (`forge-accounts`), letting checked-out workspace content define or override
> an agent would let untrusted content select host credentials. A cwd
> `agents/` (or `bottles/`) directory is now warned-about and ignored. The
> filesystem-layout trust boundary still holds — it just admits nothing from
> `$CWD`.
## Summary
Replace the single-file `bot-bottle.json` manifest with a
@@ -1,136 +0,0 @@
# PRD 0081: Reprovision gateway-dependent state on gateway bring-up
- **Status:** Active
- **Author:** claude
- **Created:** 2026-07-26
- **Issue:** #510, #512
## Summary
When the gateway is (re)built, reconcile every already-running bottle against the
fresh gateway instead of persisting the gateway's state. On a gateway cold boot,
the gateway reconciles all live bottles in one flow: **replace** each agent's
trusted CA with the freshly-minted gateway CA, **re-provision** each bottle's
git-gate repos + creds onto the gateway, and **restore** each bottle's egress
tokens. One mechanism across all three services; the CA rotates for free on every
bring-up.
## Problem
A gateway rebuild/restart silently breaks every already-running bottle:
- **CA (#510).** The gateway's mitmproxy mints a new CA on a fresh rootfs; agents
still trust the old one, so egress fails TLS verification (`SSL certificate
verification failed`).
- **git-gate (#512).** Per-bottle bare repos (`/git/<id>`) + deploy creds
(`/git-gate/creds/<id>`) live in the gateway's ephemeral rootfs; a rebuild wipes
them and the agent 404s on fetch/push.
Both are the same root cause: per-bottle gateway-dependent state is provisioned
**once, at bottle launch**, and nothing restores it for already-running bottles
when the gateway comes back. The existing launch-time reprovision
(`reprovision_bottles`) restores **only** egress tokens, and only as a side effect
of the *next* launch.
Persisting the state (a host bind-mount / a per-VM data volume per service) was
prototyped and rejected: it differs per service (a volume for the CA, another for
git-gate, reprovision for tokens), it pins the CA static forever (no rotation),
and it adds volume surface that `docker volume prune` / a wiped cache can silently
destroy (the original #450 failure mode).
## Goals / Success Criteria
- A gateway (re)boot restores **all** running bottles' gateway-dependent state
with no manual step and no relaunch — the agent's next egress / fetch / push
just works.
- **One** reconcile flow covering CA, git-gate, and egress tokens, rather than a
different mechanism per service.
- The CA **rotates** on every gateway bring-up (no long-lived CA), distributed to
running agents by the same reconcile.
- Reconcile failures are **loud, never hidden**: if a running bottle can't be
re-attached to the fresh gateway, the bring-up fails and names the bottle
rather than leaving it silently unable to reach the gateway (a bottle whose
egress just starts failing TLS is worse than a loud failure). Every bottle is
attempted first and the failures are raised together, so one bring-up surfaces
the full blast radius. (Reversed from an earlier "tolerate per-bottle
failures" draft after #519 review.)
- **All backends** (Firecracker, docker, macOS) reconcile through the *same*
contract — an abstract method on the backend base class, so a new backend
cannot forget to implement it and none drifts onto a bespoke mechanism.
## Non-goals
- Deliberate mid-session CA rotation *without* a gateway restart — `rotate_ca`
stays for that operator action.
- Changing source-IP attribution, `/resolve`, or the plane split (#469).
## Design
**The contract — `attach_bottled_agents_to_gateway()`, a concrete flow on the
backend base.** Reconciling running bottles against the current gateway is a
backend responsibility (only the backend can enumerate its agents and reach them
— firecracker over SSH, docker/macOS over `exec`/`cp`). To keep every backend
reconciling identically, the *control flow* lives in a concrete
`attach_bottled_agents_to_gateway()` on `BottleBackend` (`backend/base.py`) and
each backend overrides only three primitives:
1. `_gateway_attach_resources()` — gather what a bottle needs to attach (the
gateway CA now; egress secrets + git-gate state as this grows).
2. `_running_bottles()` — enumerate the live bottles as backend handles.
3. `_attach_bottle_to_gateway(bottle, resources)` — push the resources into one
bottle, raising `InfraLaunchError` (naming it) on failure.
The base gathers resources once, attempts every bottle, and — fail hard, never
skip — raises an aggregate `InfraLaunchError` if any failed, so a backend can't
drift onto a bespoke loop or a silent skip. (Encoding shared behaviour in the
base backend, with subclasses overriding primitives rather than control flow, is
[ADR 0006](../decisions/0006-shared-behavior-in-base-backend.md).) The host
calls it whenever the gateway is (re)brought up. It reprovisions **all**
registered bottles' gateway-dependent state: CA, git-gate, and egress tokens.
**Trigger — the gateway bring-up path.** The host calls
`attach_bottled_agents_to_gateway()` only when the gateway was actually
(re)brought up — the cold-boot branch of the infra bring-up (e.g.
`FirecrackerInfraService.ensure_running` after it boots a fresh pair), never on an
adopt of a healthy, current gateway (state intact). So it fires exactly when the
gateway was (re)booted — including orchestrator restarts, since the pair boots
together — and there is no bare-restart path that bypasses bring-up.
**Per-backend implementation.** Each backend's `attach_bottled_agents_to_gateway`
enumerates its live bottles and, for each, reconciles the three services against
the current gateway. The firecracker implementation, once the gateway VM is up
and its CA is available:
1. Map each live bottle's guest IP → `bottle_id` from the orchestrator registry
(`list_bottles`).
2. Install the **current** shared git-gate hooks (`git_gate_render_hook` /
`git_gate_render_access_hook`) into the fresh gateway once — rendered from
code, never from a bottle's possibly-stale state dir.
3. For each live agent VM (enumerated from its run dir):
- **CA:** SSH the current gateway CA into the agent's trust store and run
`update-ca-certificates` (unconditional replace — there is one gateway, so no
fingerprint match is needed).
- **git-gate:** rebuild the bottle's upstreams from its persisted git-gate
state dir (deploy key, known_hosts, upstream URL) and re-init its bare repos
+ per-repo creds under `/git/<bottle_id>`.
- **egress token:** read the agent's `ENV_VAR_SECRET` and feed
`reprovision_bottles`, restoring the orchestrator's in-memory tokens.
4. If any per-bottle step fails, the reconcile attempts the rest and then raises
an aggregate naming every bottle that failed — bring-up fails loudly rather
than leaving a bottle silently unable to reach the gateway.
**Retire the persistence prototype.** No CA data volume, no git-gate data volume
(the abandoned PRs #511 / #513). The launch-time `_reprovision_running_bottles`
call folds into this bring-up reconcile, so egress tokens are restored on the same
cold-boot trigger (an adopt needs no restore — the orchestrator never restarted).
**CA rotation.** Because the gateway rootfs is ephemeral, every cold boot mints a
fresh CA; reconcile is what distributes it, so a routine gateway rebuild doubles
as a CA rotation with zero extra machinery.
## Open questions
- Docker's existing host-bind-mounted CA (`host_gateway_ca_dir`): once docker's
`attach_bottled_agents_to_gateway` pushes the CA to running agents on bring-up,
the bind-mount is redundant — drop it (so docker rotates like firecracker) or
keep it as belt-and-suspenders? Leaning drop, for one behaviour across backends.
@@ -0,0 +1,391 @@
# PRD 0082: Trusted agent forge identity and guidance
- **Status:** Accepted
- **Author:** didericis-claude
- **Created:** 2026-07-25
- **Issue:** #423
## Summary
Move author identity and named forge configurations into host-trusted agent
definitions. A bottle may optionally associate a git-gate repository with one
of the selected agent's forge aliases. When associated, bot-bottle gives the
agent non-secret, provider-specific system guidance for that repository and
routes authenticated forge API calls through the egress proxy without exposing
the forge token to the bottle. The authenticated account is inferred from the
identity that owns that token; it is not separately declared in the manifest.
Repository-local agent and bottle definitions are no longer discovered. Once
agent definitions can select a host forge credential, allowing checked-out
repository content to define or override an agent would let untrusted workspace
content select host identities and secrets.
This PRD is deliberately limited to identity ownership, manifest trust, forge
API access, and generated guidance. Per-activation signing, signature
enforcement, commit attribution, and the commit audit model move to a follow-up
PRD.
Successor to:
- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to
override home agents. This PRD removes that trust path: agents and bottles are
loaded only from the host-owned `~/.bot-bottle` tree.
- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — put name/email in
`git-gate.user` while keeping them claimed rather than vouched. This PRD moves
those agent properties out of git-gate and into the trusted agent definition.
- **PRD 0048 (deploy-key provisioning, #169)** — remains the Git push
capability. A forge actor token is a separate API credential and never
replaces a deploy key.
## Problem
### Forge workflow context is missing
The agent prompt does not know which forge backs a git-gate repository, which
API base URL to use, or that authenticated requests must go through the egress
proxy. Bespoke prompt text has drifted between agents. Missing guidance has
already caused incorrect PR behavior, including attempts to use Gitea AGit
review refs instead of a normal branch-backed pull request.
### Identity is owned by the wrong layer
`git-gate.user` puts an agent property on a repository transport component. An
author identity and set of forge credentials should follow the agent across
bottles and repositories. Git-gate should own Git transport policy, not decide
who the agent is.
### Repository-local agents become a credential-selection path
Today `$CWD/.bot-bottle/agents/*.md` can define new agents and override
home-resident agents. If an agent definition may reference an operator-provided
forge token, a malicious repository could select a host credential merely by
being the current workspace. Repository-local bottles are already ignored;
agents need the same host-only boundary.
## Goals / Success Criteria
- **Agent-owned identity.** Author name/email and named forge configurations
live on the agent definition, not under `git-gate`.
- **Trusted definitions only.** Agent and bottle files are discovered only
under `~/.bot-bottle/{agents,bottles}`. Repository-local definitions never
contribute names, defaults, or overrides.
- **Optional repository association.** A bottle repository may name one forge
alias from the selected agent. Repositories without `forge` retain current
behavior and generate no forge guidance or credential route.
- **Proxy-held forge credential.** The host resolves the selected forge
alias's token reference and gives the value only to the egress proxy. The
bottle receives neither the token nor a credential file containing it.
- **Forge-aware system guidance.** For associated repositories, bot-bottle
generates provider-specific instructions describing the API base URL,
repository/account association, proxy-authenticated access, and correct PR
workflow.
- **No secret prompt material.** Neither token values nor token
environment-variable names appear in the prompt or bottle environment.
- **Fail closed.** Unknown account references, unsupported/non-HTTPS URLs,
missing host secrets, and misplaced agent/bottle fields fail before creating
the bottle.
- **Push capability unchanged.** Git transport remains PRD 0048 deploy keys.
The forge actor token is only for API actions such as opening, reviewing, and
commenting on pull requests.
## Non-goals
- **Commit signing or signature enforcement.** No signing key is minted and
git-gate does not verify commit signatures in this PRD.
- **Commit attribution or audit tables.** There is no trustworthy commit
observation event in this slice. A follow-up signing PRD owns activation keys,
control-plane verification, and any configured-author-versus-claimed-author
audit model.
- **Cryptographically vouched author identity.** `author` configures Git and
identifies the agent actor, but commit author/committer fields remain claims
under ADR 0002.
- **Forge account or token minting.** The operator creates the agent-specific
account/token out of band. Bot-bottle references an existing host secret; it
does not create, rotate, or revoke that credential.
- **Forge-side commit attribution surfaces.** No commit status, signing-key
registration, or "Verified" badge.
- **Provider-generic arbitrary prompts.** Gitea is the first supported forge.
A future provider adds typed validation and generated guidance in code rather
than accepting repository-supplied prompt text.
## Design
### Ownership model
The resolved bottled agent is an agent definition composed with a bottle:
| Part | Source | Role |
|------|--------|------|
| Author identity | agent `author` | configures Git name/email and identifies the agent's claimed author |
| Forge configurations | agent `forge-accounts` | maps forge aliases to API origins and host token references available to this agent; token ownership determines account identity |
| Git repository | bottle `git-gate.repos` | configures Git transport, host verification, and push capability |
| Repository/forge association | bottle repo `forge` | optionally selects an agent forge alias for guidance and API access |
This boundary keeps identity on the agent, capability/policy on the bottle, and
transport enforcement in git-gate.
### Agent manifest
Agent identity and forge accounts live only in
`~/.bot-bottle/agents/<name>.md`:
```yaml
---
author:
name: didericis-claude
email: eric+claude@dideric.is
forge-accounts:
didericis-gitea:
auth:
type: token
token_secret: GITEA_CLAUDE_TOKEN
url: https://gitea.dideric.is/api/v1
---
```
`author` contains:
- `name`: non-empty string;
- `email`: non-empty string passing the existing Git identity validation.
The resolved values populate `user.name` and `user.email`. Existing
`git-gate.user` fields fail with migration guidance to move the values into the
selected home agent's `author` block. There is no compatibility period where a
bottle identity silently overrides the agent identity.
`forge-accounts` is a map whose keys are **forge aliases** and follow the
manifest's existing kebab-case identifier grammar
(`[a-z][a-z0-9-]*`). Each forge entry contains:
- `url`: a canonical HTTPS Gitea API base URL;
- `auth.type`: `token` in this slice;
- `auth.token_secret`: the name of a host environment variable containing the
operator-provided, agent-specific API token.
The token's owner determines the authenticated forge account; there is no
separate account-name field. The token secret name is host configuration, not
bottle configuration. The token value is resolved only if a selected bottle
repository references the forge alias.
### Bottle manifest
Repository policy remains in `~/.bot-bottle/bottles/<name>.md`:
```yaml
---
git-gate:
repos:
bot-bottle:
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
provisioned_key:
provider: gitea
token_env: GITEA_DEPLOY_TOKEN
host_key: "ssh-ed25519 AAAA..."
forge: didericis-gitea
---
```
`git-gate.repos.<name>.forge` is optional:
- When absent, the repository behaves exactly as it does today. Bot-bottle does
not resolve a forge actor token and does not add forge-specific instructions
for that repository.
- When present, it must match a forge alias in `forge-accounts` on the selected
agent.
The resolved association enables a scoped proxy credential route and adds the
repository/forge relationship to generated system guidance.
`provisioned_key.token_env` remains the deploy-key administration credential
from PRD 0048. It is separate from `forge-accounts.*.auth.token_secret`: the
former provisions Git push capability, while the latter performs API actions as
the agent.
`author` and `forge-accounts` are agent-only. `git-gate.repos`, including
`forge`, is bottle-only. Validation errors point to the correct file and trust
domain rather than ignoring misplaced keys.
### Definition trust and discovery
Only the host-owned manifest tree is authoritative:
- Agents: `~/.bot-bottle/agents/*.md`
- Bottles: `~/.bot-bottle/bottles/*.md`
`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer
overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If
either repository-local directory contains manifest files, bot-bottle emits a
warning that they are ignored and points to the corresponding home path.
Every discovery and resolution surface uses the same home-only index:
- agent enumeration and selectors;
- `require_agent`;
- lazy `load_for_agent`;
- default-agent/default-bottle resolution;
- dashboard and headless launch paths.
There must be no alternate direct-path load that can still select a workspace
definition.
Workspace instructions remain repository content (for example `AGENTS.md`),
but runtime policy, host secret references, and actor identity do not.
Programmatic in-memory manifests remain available for tests and trusted internal
composition; they are not a filesystem discovery path.
### Forge URL validation
The host parses and canonicalizes each referenced forge alias's URL before
creating any runtime resources:
- scheme must be `https`;
- userinfo, query, and fragment are forbidden;
- hostname must be present;
- the path must be a supported Gitea API base (initially `/api/v1`, with
normalization of a trailing slash);
- visually different inputs that canonicalize to the same origin/prefix are
deduplicated;
- unsupported providers or path shapes fail closed.
The provider is determined by typed support in bot-bottle, not by prompt text
from a repository. Adding another provider requires a validator, auth scheme,
and guidance renderer.
### Proxy credential provisioning
For each distinct forge alias referenced by at least one selected bottle
repository, the host:
1. Resolves `auth.token_secret` from the host environment and rejects a missing
or empty value.
2. Copies the token value only into the egress proxy's credential environment.
3. Adds an inspected route scoped to the canonical forge origin and API prefix.
4. Configures the provider authentication scheme (`token` for Gitea) so the
proxy injects authentication.
The bottle makes an unauthenticated request to the configured HTTPS API URL.
The token is not copied into the bottle, `.gitconfig`, generated prompt,
workspace, or process environment visible to the agent.
If several repositories reference the same forge alias, they share one
credential route. Unreferenced forge aliases resolve no secret and create no
route.
### Generated system guidance
Bot-bottle appends a generated, non-secret section to its existing system
prompt file. It is derived from validated typed fields, not copied Markdown from
a repository.
For each associated repository, Gitea guidance includes:
- forge alias (for example `didericis-gitea`);
- API base URL (for example `https://gitea.dideric.is/api/v1`);
- the git-gate repository name tied to that forge;
- the instruction to call the configured HTTPS API through the proxy without
reading, printing, or manually attaching an authorization token;
- the distinction that Git pushes still use the git-gate remote;
- the requirement to create/update a normal `refs/heads/<branch>` and open a
branch-backed pull request through the API;
- the prohibition on pushing `refs/for/*`, `refs/draft/*`, or
`refs/for-review/*`;
- the instruction to use the API for reviews/comments and verify returned
object state before claiming completion.
The prompt contains neither the token value nor its `token_secret` name.
Repositories without `forge` are omitted from this section. If no selected
repository has a forge association, no forge guidance section is generated.
### Failure and lifecycle behavior
Forge configuration is validated before bottle creation. A bad association or
credential must not leave a partially-created bottle or proxy.
The operator-owned token is not minted, rotated, or revoked by bot-bottle. On
teardown, stopping the egress proxy discards the activation's in-memory/runtime
copy. Later activations resolve the current host secret again.
Logs may include the forge alias, canonical API origin, and repository name.
They must never contain the token value. Errors for missing secrets name the
configuration field and host environment variable, but do not print any value.
## Migration
This change is intentionally breaking at the manifest trust boundary:
1. Move each home bottle/agent `git-gate.user.name` and `.email` into the
corresponding home agent's `author`.
2. Add agent-specific `forge-accounts` only to home agent definitions.
3. Add optional `forge` associations to home bottle repository entries.
4. Move any `$CWD/.bot-bottle/agents/*.md` that should remain selectable into
`~/.bot-bottle/agents/`. Repository copies are ignored thereafter.
5. Keep repository-specific behavioral instructions in `AGENTS.md` or another
workspace instruction file; do not put runtime identity or secret references
there.
Errors and warnings link to this migration rather than silently changing which
identity or definition is active.
## Follow-up: signed commits and attribution
A separate PRD will consume the trusted agent `author` introduced here and own:
- per-activation signing key minting and sidecar isolation;
- commit-time signing through a forwarded signing capability;
- git-gate rejection of unsigned/wrong-key new commits;
- independent control-plane object-ID recomputation and signature verification;
- activation and attributed-commit audit tables;
- storage of configured agent author separately from each commit's unenforced
claimed author/committer;
- post-teardown verification and key-retention policy.
That PRD must not reintroduce identity under git-gate or expand repository-local
manifest trust.
## Implementation chunks
1. **This PRD.** Establish the identity, forge, and filesystem trust boundary.
2. **Home-only definitions.** Remove cwd agents from discovery, override,
enumeration, lazy loading, defaults, and selectors. Warn on ignored cwd
agent/bottle files and provide migration guidance.
3. **Manifest schema.** Add agent-only `author` and `forge-accounts`; remove
`git-gate.user`; add optional bottle-only
`git-gate.repos.<name>.forge`. Validate account names, composition
references, field placement, and Gitea API URLs.
4. **Proxy provisioning.** Lazily resolve only referenced token secrets and
create scoped authenticated Gitea API routes without putting credentials in
the bottle.
5. **Prompt generation.** Render typed Gitea/repository workflow guidance into
the existing bot-bottle prompt path for associated repositories only.
6. **Docs and migration.** Update README examples, PRD 0011-facing discovery
documentation, agent/bottle schema docs, and error guidance.
## Testing strategy
- **Trust boundary:** cwd agent files are ignored with a warning, cannot
override a home agent, are absent from enumeration/selectors/defaults, and
cannot be loaded by name. Cwd bottle behavior remains home-only.
- **Agent schema:** `author` and `forge-accounts` parse; malformed identities,
non-kebab account names, unknown fields, invalid auth types, and misplaced
git-gate fields fail clearly.
- **Bottle schema:** `forge` is optional; absent associations preserve current
behavior; present associations resolve against the selected agent; unknown or
misplaced associations fail before launch.
- **URL validation:** HTTPS Gitea API bases pass and canonicalize; HTTP,
userinfo, query, fragment, missing host, unsupported paths, and unsupported
providers fail closed.
- **Secret handling:** only referenced accounts resolve environment secrets;
missing/empty secrets fail before runtime creation; token values are absent
from bottle env, prompt, generated config, logs, and workspace; token secret
names are absent from the bottle and prompt.
- **Proxy behavior:** associated API requests receive proxy-injected Gitea
authentication scoped to the configured origin/prefix; unrelated hosts and
paths receive no credential.
- **Prompt behavior:** guidance names account/API/repository associations,
branch-backed PR workflow, prohibited AGit refs, and mutation verification;
repositories without `forge` are omitted; no associations means no section.
- **Migration:** legacy `git-gate.user` fails with an `author` migration pointer;
ignored cwd definitions warn with the target home path.
## Open questions
- None.
+3 -4
View File
@@ -5,10 +5,9 @@ model: opus
bottle: dev
skills:
- init-prd
git-gate:
user:
name: implementer-bot
email: eric+implementer@dideric.is
author:
name: implementer-bot
email: eric+implementer@dideric.is
---
You are a feature-implementation agent running inside an ephemeral
+5
View File
@@ -0,0 +1,5 @@
{
"DOCKER_CLI_BASE_IMAGE": "docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d",
"NODE_BASE_IMAGE": "node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba",
"PYTHON_BASE_IMAGE": "python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de"
}
+5
View File
@@ -38,8 +38,13 @@ include = ["bot_bottle*"]
bot_bottle = [
"gateway/egress/entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/claude/package.json",
"contrib/claude/package-lock.json",
"contrib/codex/Dockerfile",
"contrib/codex/codex-package_SHA256SUMS",
"contrib/pi/Dockerfile",
"contrib/pi/package.json",
"contrib/pi/package-lock.json",
"backend/firecracker/netpool.defaults.env",
"backend/macos_container/nested-containers-init.sh",
]
+5
View File
@@ -0,0 +1,5 @@
# Runtime-only third-party dependency for Dockerfile.gateway.
#
# Regenerate requirements.gateway.lock with the image-input refresh workflow;
# Dockerfile.gateway installs the lock with --require-hashes.
mitmproxy==11.1.3
+837
View File
@@ -0,0 +1,837 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --generate-hashes --output-file=requirements.gateway.lock requirements.gateway.in
#
aioquic==1.2.0 \
--hash=sha256:1de513772fd04ff38028fdf748a9e2dec33d7aa2fbf67fda3011d9a85b620c54 \
--hash=sha256:2466499759b31ea4f1d17f4aeb1f8d4297169e05e3c1216d618c9757f4dd740d \
--hash=sha256:358e2b9c1e0c24b9933094c3c2cf990faf44d03b64d6f8ff79b4b3f510c6c268 \
--hash=sha256:3976b75e82d83742c8b811e38d273eda2ca7f81394b6a85da33a02849c5f1d9d \
--hash=sha256:3e23964dfb04526ade6e66f5b7cd0c830421b8138303ab60ba6e204015e7cb0b \
--hash=sha256:43ae3b11d43400a620ca0b4b4885d12b76a599c2cbddba755f74bebfa65fe587 \
--hash=sha256:6371c3afa1036294e1505fdbda8c147bc41c5b6709a47459e8c1b4eec41a86ef \
--hash=sha256:6e418c92898a0af306e6f1b6a55a0d3d2597001c57a7b1ba36cf5b47bf41233b \
--hash=sha256:6fe683943ea3439dd0aca05ff80e85a552d4b39f9f34858c76ac54c205990e88 \
--hash=sha256:7dcc212bb529900757d8e99a76198b42c2a978ce735a1bfca394033c16cfc33c \
--hash=sha256:81650d59bef05c514af2cfdcb2946e9d13367b745e68b36881d43630ef563d38 \
--hash=sha256:84d733332927b76218a3b246216104116f766f5a9b2308ec306cd017b3049660 \
--hash=sha256:8e600da7aa7e4a7bc53ee8f45fd66808032127ae00938c119ac77d66633b8961 \
--hash=sha256:910d8c91da86bba003d491d15deaeac3087d1b9d690b9edc1375905d8867b742 \
--hash=sha256:bb917143e7a4de5beba1e695fa89f2b05ef080b450dea07338cc67a9c75e0a4d \
--hash=sha256:c22689c33fe4799624aed6faaba0af9e6ea7d31ac45047745828ee68d67fe1d9 \
--hash=sha256:c332cffa3c2124e5db82b2b9eb2662bd7c39ee2247606b74de689f6d3091b61a \
--hash=sha256:cbe7167b2faee887e115d83d25332c4b8fa4604d5175807d978cb4fe39b4e36e \
--hash=sha256:cd75015462ca5070a888110dc201f35a9f4c7459f9201b77adc3c06013611bb8 \
--hash=sha256:e2c3c127cc3d9eac7a6d05142036bf4b2c593d750a115a2fa42c1f86cbe8c0a0 \
--hash=sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a \
--hash=sha256:e7ce10198f8efa91986ad8ac83fa08e50972e0aacde45bdaf7b9365094e72c0c \
--hash=sha256:f209ad5edbff8239e994c189dc74428420957448a190f4343faee4caedef4eee \
--hash=sha256:f81e7946f09501a7c27e3f71b84a455e6bf92346fb5a28ef2d73c9d564463c63 \
--hash=sha256:f91263bb3f71948c5c8915b4d50ee370004f20a416f67fab3dcc90556c7e7199 \
--hash=sha256:fcc1eb083ed9f8d903482e375281c8c26a5ed2b6bee5ee2be3f13275d8fdb146
# via mitmproxy
argon2-cffi==23.1.0 \
--hash=sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08 \
--hash=sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea
# via mitmproxy
argon2-cffi-bindings==25.1.0 \
--hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \
--hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \
--hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \
--hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \
--hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \
--hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \
--hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \
--hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \
--hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \
--hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \
--hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \
--hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \
--hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \
--hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \
--hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \
--hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \
--hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \
--hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \
--hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \
--hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \
--hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \
--hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \
--hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \
--hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \
--hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \
--hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb
# via argon2-cffi
asgiref==3.8.1 \
--hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
--hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
# via mitmproxy
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
# via service-identity
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
brotli==1.1.0 \
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
--hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \
--hash=sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419 \
--hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
--hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \
--hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
--hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
--hash=sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757 \
--hash=sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2 \
--hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \
--hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \
--hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \
--hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \
--hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \
--hash=sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0 \
--hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \
--hash=sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943 \
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
--hash=sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28 \
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
--hash=sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f \
--hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \
--hash=sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547 \
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
--hash=sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0 \
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
--hash=sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a \
--hash=sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb \
--hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
--hash=sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2 \
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
--hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \
--hash=sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec \
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
--hash=sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c \
--hash=sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38 \
--hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
--hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \
--hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \
--hash=sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368 \
--hash=sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c \
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
--hash=sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f \
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
--hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \
--hash=sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8 \
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
--hash=sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c \
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
--hash=sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7 \
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
--hash=sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9 \
--hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \
--hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \
--hash=sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5 \
--hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \
--hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \
--hash=sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b \
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
--hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \
--hash=sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648 \
--hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \
--hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \
--hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
--hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \
--hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \
--hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \
--hash=sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2 \
--hash=sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0 \
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
--hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \
--hash=sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75 \
--hash=sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5 \
--hash=sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f \
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
--hash=sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f \
--hash=sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb \
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
--hash=sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111 \
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
--hash=sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01 \
--hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \
--hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
--hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \
--hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \
--hash=sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7 \
--hash=sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c \
--hash=sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284 \
--hash=sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52 \
--hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \
--hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
--hash=sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1 \
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
--hash=sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839 \
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
--hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \
--hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \
--hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \
--hash=sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089 \
--hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \
--hash=sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b \
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \
--hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \
--hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \
--hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \
--hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064
# via mitmproxy
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# aioquic
# mitmproxy
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via
# argon2-cffi-bindings
# cryptography
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via flask
cryptography==44.0.3 \
--hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \
--hash=sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43 \
--hash=sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645 \
--hash=sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8 \
--hash=sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44 \
--hash=sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d \
--hash=sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f \
--hash=sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d \
--hash=sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54 \
--hash=sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9 \
--hash=sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137 \
--hash=sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f \
--hash=sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c \
--hash=sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334 \
--hash=sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c \
--hash=sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b \
--hash=sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2 \
--hash=sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375 \
--hash=sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88 \
--hash=sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5 \
--hash=sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647 \
--hash=sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c \
--hash=sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359 \
--hash=sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5 \
--hash=sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d \
--hash=sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028 \
--hash=sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01 \
--hash=sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904 \
--hash=sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d \
--hash=sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93 \
--hash=sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06 \
--hash=sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff \
--hash=sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76 \
--hash=sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff \
--hash=sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759 \
--hash=sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4 \
--hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053
# via
# aioquic
# mitmproxy
# pyopenssl
# service-identity
flask==3.1.0 \
--hash=sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac \
--hash=sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136
# via mitmproxy
h11==0.14.0 \
--hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \
--hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
# via
# mitmproxy
# wsproto
h2==4.1.0 \
--hash=sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d \
--hash=sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb
# via mitmproxy
hpack==4.2.0 \
--hash=sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0 \
--hash=sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986
# via h2
hyperframe==6.1.0 \
--hash=sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5 \
--hash=sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08
# via
# h2
# mitmproxy
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
kaitaistruct==0.10 \
--hash=sha256:a044dee29173d6afbacf27bcac39daf89b654dd418cfa009ab82d9178a9ae52a \
--hash=sha256:a97350919adbf37fda881f75e9365e2fb88d04832b7a4e57106ec70119efb235
# via mitmproxy
ldap3==2.9.1 \
--hash=sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70 \
--hash=sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f
# via mitmproxy
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# jinja2
# werkzeug
mitmproxy==11.1.3 \
--hash=sha256:2305880b46465d1a9bdcdac369655826f588d05f382b082249a3e532a0e52952
# via -r requirements.gateway.in
mitmproxy-linux==0.11.5 \
--hash=sha256:00a40d08a1522d5718e9ff87458a950f06f62e5374d154d851122c0eb41c5dc0 \
--hash=sha256:544be1db84575fc8ecc71fb566032cabe4a65a4891d5bd0dc688e3023b49a18a \
--hash=sha256:d7ce0b91d7a510009d532e6abbebe59f027a011fa745b13faa5b4d9ebe92abf5 \
--hash=sha256:e6a31faf244a9e3d44db02e3e3301aa2e699da67188820982a93028884f4cba8 \
--hash=sha256:ee3782fe4e7ccc6a899fa0ef5ad3e35a3ec358587304bd4d212188d2462c8f82
# via mitmproxy-rs
mitmproxy-rs==0.11.5 \
--hash=sha256:05f0da03165c2ee2803f91e6648bc9409692f42d796cbaf3fec5a20754ca8c39 \
--hash=sha256:2f668dc92573cc3c3ba8c75b166276d846ce7321daf37f4a68bd837538298c5c \
--hash=sha256:5353ad0c828aaa37ac53511f3960e39c0888848565f5faa3ea09e205ed8a7350 \
--hash=sha256:5a4ffe6d20b3a0edb47b40cd60e7b62709c29e8adf2573514cc0abd1442acf63 \
--hash=sha256:971241cb70bad87b55f12bc6e8d7dd3efd02a1acbe1730703e2cfeeb6edd3908
# via mitmproxy
msgpack==1.1.0 \
--hash=sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b \
--hash=sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf \
--hash=sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca \
--hash=sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330 \
--hash=sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f \
--hash=sha256:13599f8829cfbe0158f6456374e9eea9f44eee08076291771d8ae93eda56607f \
--hash=sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39 \
--hash=sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247 \
--hash=sha256:3180065ec2abbe13a4ad37688b61b99d7f9e012a535b930e0e683ad6bc30155b \
--hash=sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c \
--hash=sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7 \
--hash=sha256:3df7e6b05571b3814361e8464f9304c42d2196808e0119f55d0d3e62cd5ea044 \
--hash=sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6 \
--hash=sha256:42f754515e0f683f9c79210a5d1cad631ec3d06cea5172214d2176a42e67e19b \
--hash=sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0 \
--hash=sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2 \
--hash=sha256:46c34e99110762a76e3911fc923222472c9d681f1094096ac4102c18319e6468 \
--hash=sha256:471e27a5787a2e3f974ba023f9e265a8c7cfd373632247deb225617e3100a3c7 \
--hash=sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734 \
--hash=sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434 \
--hash=sha256:4d1b7ff2d6146e16e8bd665ac726a89c74163ef8cd39fa8c1087d4e52d3a2325 \
--hash=sha256:53258eeb7a80fc46f62fd59c876957a2d0e15e6449a9e71842b6d24419d88ca1 \
--hash=sha256:534480ee5690ab3cbed89d4c8971a5c631b69a8c0883ecfea96c19118510c846 \
--hash=sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88 \
--hash=sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420 \
--hash=sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e \
--hash=sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2 \
--hash=sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59 \
--hash=sha256:646afc8102935a388ffc3914b336d22d1c2d6209c773f3eb5dd4d6d3b6f8c1cb \
--hash=sha256:64fc9068d701233effd61b19efb1485587560b66fe57b3e50d29c5d78e7fef68 \
--hash=sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915 \
--hash=sha256:685ec345eefc757a7c8af44a3032734a739f8c45d1b0ac45efc5d8977aa4720f \
--hash=sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701 \
--hash=sha256:73322a6cc57fcee3c0c57c4463d828e9428275fb85a27aa2aa1a92fdc42afd7b \
--hash=sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d \
--hash=sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa \
--hash=sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d \
--hash=sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd \
--hash=sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc \
--hash=sha256:7e7b853bbc44fb03fbdba34feb4bd414322180135e2cb5164f20ce1c9795ee48 \
--hash=sha256:879a7b7b0ad82481c52d3c7eb99bf6f0645dbdec5134a4bddbd16f3506947feb \
--hash=sha256:8a706d1e74dd3dea05cb54580d9bd8b2880e9264856ce5068027eed09680aa74 \
--hash=sha256:8a84efb768fb968381e525eeeb3d92857e4985aacc39f3c47ffd00eb4509315b \
--hash=sha256:8cf9e8c3a2153934a23ac160cc4cba0ec035f6867c8013cc6077a79823370346 \
--hash=sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e \
--hash=sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6 \
--hash=sha256:914571a2a5b4e7606997e169f64ce53a8b1e06f2cf2c3a7273aa106236d43dd5 \
--hash=sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f \
--hash=sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5 \
--hash=sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b \
--hash=sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c \
--hash=sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f \
--hash=sha256:c40ffa9a15d74e05ba1fe2681ea33b9caffd886675412612d93ab17b58ea2fec \
--hash=sha256:c5a91481a3cc573ac8c0d9aace09345d989dc4a0202b7fcb312c88c26d4e71a8 \
--hash=sha256:c921af52214dcbb75e6bdf6a661b23c3e6417f00c603dd2070bccb5c3ef499f5 \
--hash=sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d \
--hash=sha256:d8ce0b22b890be5d252de90d0e0d119f363012027cf256185fc3d474c44b1b9e \
--hash=sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e \
--hash=sha256:e0856a2b7e8dcb874be44fea031d22e5b3a19121be92a1e098f46068a11b0870 \
--hash=sha256:e1f3c3d21f7cf67bcf2da8e494d30a75e4cf60041d98b3f79875afb5b96f3a3f \
--hash=sha256:f1ba6136e650898082d9d5a5217d5906d1e138024f836ff48691784bbe1adf96 \
--hash=sha256:f3e9b4936df53b970513eac1758f3882c88658a220b58dcc1e39606dccaaf01c \
--hash=sha256:f80bc7d47f76089633763f952e67f8214cb7b3ee6bfa489b3cb6a84cfac114cd \
--hash=sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788
# via mitmproxy
passlib==1.7.4 \
--hash=sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1 \
--hash=sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04
# via mitmproxy
publicsuffix2==2.20191221 \
--hash=sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b \
--hash=sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893
# via mitmproxy
pyasn1==0.6.4 \
--hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \
--hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b
# via
# ldap3
# pyasn1-modules
# service-identity
pyasn1-modules==0.4.2 \
--hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
--hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
# via service-identity
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pylsqpack==0.3.24 \
--hash=sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9 \
--hash=sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9 \
--hash=sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c \
--hash=sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767 \
--hash=sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890 \
--hash=sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d \
--hash=sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a \
--hash=sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff \
--hash=sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b \
--hash=sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0 \
--hash=sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c \
--hash=sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc
# via aioquic
pyopenssl==25.0.0 \
--hash=sha256:424c247065e46e76a37411b9ab1782541c23bb658bf003772c3405fbaa128e90 \
--hash=sha256:cd2cef799efa3936bb08e8ccb9433a575722b9dd986023f1cabc4ae64e9dac16
# via
# aioquic
# mitmproxy
pyparsing==3.2.1 \
--hash=sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1 \
--hash=sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a
# via mitmproxy
pyperclip==1.9.0 \
--hash=sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310
# via mitmproxy
ruamel-yaml==0.18.10 \
--hash=sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58 \
--hash=sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1
# via mitmproxy
ruamel-yaml-clib==0.2.15 \
--hash=sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490 \
--hash=sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14 \
--hash=sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6 \
--hash=sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a \
--hash=sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9 \
--hash=sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d \
--hash=sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5 \
--hash=sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3 \
--hash=sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe \
--hash=sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c \
--hash=sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc \
--hash=sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf \
--hash=sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900 \
--hash=sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a \
--hash=sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa \
--hash=sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6 \
--hash=sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd \
--hash=sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25 \
--hash=sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600 \
--hash=sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf \
--hash=sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642 \
--hash=sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614 \
--hash=sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf \
--hash=sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000 \
--hash=sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb \
--hash=sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690 \
--hash=sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e \
--hash=sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137 \
--hash=sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d \
--hash=sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401 \
--hash=sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f \
--hash=sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2 \
--hash=sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471 \
--hash=sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed \
--hash=sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524 \
--hash=sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60 \
--hash=sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef \
--hash=sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043 \
--hash=sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03 \
--hash=sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815 \
--hash=sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77 \
--hash=sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4 \
--hash=sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d \
--hash=sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467 \
--hash=sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7 \
--hash=sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e \
--hash=sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec \
--hash=sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4 \
--hash=sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd \
--hash=sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff \
--hash=sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c \
--hash=sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862 \
--hash=sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974 \
--hash=sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922 \
--hash=sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a \
--hash=sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d \
--hash=sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262 \
--hash=sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144 \
--hash=sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1 \
--hash=sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51 \
--hash=sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f
# via ruamel-yaml
service-identity==24.2.0 \
--hash=sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85 \
--hash=sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09
# via aioquic
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via mitmproxy
tornado==6.4.2 \
--hash=sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803 \
--hash=sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec \
--hash=sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482 \
--hash=sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634 \
--hash=sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38 \
--hash=sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b \
--hash=sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c \
--hash=sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf \
--hash=sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946 \
--hash=sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73 \
--hash=sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1
# via mitmproxy
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# pyopenssl
# urwid
urwid==2.6.16 \
--hash=sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2 \
--hash=sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797
# via mitmproxy
wcwidth==0.8.2 \
--hash=sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda \
--hash=sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85
# via urwid
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via flask
wsproto==1.2.0 \
--hash=sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065 \
--hash=sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736
# via mitmproxy
zstandard==0.23.0 \
--hash=sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473 \
--hash=sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916 \
--hash=sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15 \
--hash=sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072 \
--hash=sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4 \
--hash=sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e \
--hash=sha256:1bfe8de1da6d104f15a60d4a8a768288f66aa953bbe00d027398b93fb9680b26 \
--hash=sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8 \
--hash=sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5 \
--hash=sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd \
--hash=sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c \
--hash=sha256:29a2bc7c1b09b0af938b7a8343174b987ae021705acabcbae560166567f5a8db \
--hash=sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5 \
--hash=sha256:2ef3775758346d9ac6214123887d25c7061c92afe1f2b354f9388e9e4d48acfc \
--hash=sha256:2f146f50723defec2975fb7e388ae3a024eb7151542d1599527ec2aa9cacb152 \
--hash=sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269 \
--hash=sha256:32ba3b5ccde2d581b1e6aa952c836a6291e8435d788f656fe5976445865ae045 \
--hash=sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e \
--hash=sha256:379b378ae694ba78cef921581ebd420c938936a153ded602c4fea612b7eaa90d \
--hash=sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a \
--hash=sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb \
--hash=sha256:4051e406288b8cdbb993798b9a45c59a4896b6ecee2f875424ec10276a895740 \
--hash=sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105 \
--hash=sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274 \
--hash=sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2 \
--hash=sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58 \
--hash=sha256:50a80baba0285386f97ea36239855f6020ce452456605f262b2d33ac35c7770b \
--hash=sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4 \
--hash=sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db \
--hash=sha256:53ea7cdc96c6eb56e76bb06894bcfb5dfa93b7adcf59d61c6b92674e24e2dd5e \
--hash=sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9 \
--hash=sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0 \
--hash=sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813 \
--hash=sha256:61062387ad820c654b6a6b5f0b94484fa19515e0c5116faf29f41a6bc91ded6e \
--hash=sha256:61f89436cbfede4bc4e91b4397eaa3e2108ebe96d05e93d6ccc95ab5714be512 \
--hash=sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0 \
--hash=sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b \
--hash=sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48 \
--hash=sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a \
--hash=sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772 \
--hash=sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed \
--hash=sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373 \
--hash=sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea \
--hash=sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd \
--hash=sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f \
--hash=sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc \
--hash=sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23 \
--hash=sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2 \
--hash=sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db \
--hash=sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70 \
--hash=sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259 \
--hash=sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9 \
--hash=sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700 \
--hash=sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003 \
--hash=sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba \
--hash=sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a \
--hash=sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c \
--hash=sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90 \
--hash=sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690 \
--hash=sha256:a05e6d6218461eb1b4771d973728f0133b2a4613a6779995df557f70794fd60f \
--hash=sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840 \
--hash=sha256:a4ae99c57668ca1e78597d8b06d5af837f377f340f4cce993b551b2d7731778d \
--hash=sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9 \
--hash=sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35 \
--hash=sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd \
--hash=sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a \
--hash=sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea \
--hash=sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1 \
--hash=sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573 \
--hash=sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09 \
--hash=sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094 \
--hash=sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78 \
--hash=sha256:b8c0bd73aeac689beacd4e7667d48c299f61b959475cdbb91e7d3d88d27c56b9 \
--hash=sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5 \
--hash=sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9 \
--hash=sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391 \
--hash=sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847 \
--hash=sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2 \
--hash=sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c \
--hash=sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2 \
--hash=sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057 \
--hash=sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20 \
--hash=sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d \
--hash=sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4 \
--hash=sha256:e2d1a054f8f0a191004675755448d12be47fa9bebbcffa3cdf01db19f2d30a54 \
--hash=sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171 \
--hash=sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e \
--hash=sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160 \
--hash=sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b \
--hash=sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58 \
--hash=sha256:f83fa6cae3fff8e98691248c9320356971b59678a17f20656a9e59cd32cee6d8 \
--hash=sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33 \
--hash=sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a \
--hash=sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880 \
--hash=sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca \
--hash=sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b \
--hash=sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69
# via mitmproxy
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""Fail when a supported image reintroduces mutable build inputs."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DOCKERFILES = (
Path("Dockerfile.gateway"),
Path("Dockerfile.orchestrator"),
Path("Dockerfile.orchestrator.fc"),
Path("bot_bottle/contrib/claude/Dockerfile"),
Path("bot_bottle/contrib/codex/Dockerfile"),
Path("bot_bottle/contrib/pi/Dockerfile"),
)
NPM_MANIFESTS = (
Path("bot_bottle/contrib/claude/package.json"),
Path("bot_bottle/contrib/pi/package.json"),
)
IMAGE_BUILD_ARGS = Path("image-build-args.json")
CODEX_CHECKSUMS = Path("bot_bottle/contrib/codex/codex-package_SHA256SUMS")
REQUIRED_CODEX_ASSETS = frozenset({
"codex-package-aarch64-unknown-linux-musl.tar.gz",
"codex-package-x86_64-unknown-linux-musl.tar.gz",
})
REQUIRED_BASE_ARGS = frozenset({
"DOCKER_CLI_BASE_IMAGE",
"NODE_BASE_IMAGE",
"PYTHON_BASE_IMAGE",
})
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
_EXACT_NPM_VERSION = re.compile(
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$",
)
_SNAPSHOT = re.compile(r"ARG\s+DEBIAN_SNAPSHOT=\d{8}T\d{6}Z")
_NETWORK_TO_SHELL = re.compile(
r"(?:curl|wget)\b[^|\n]*\|\s*(?:ba)?sh\b",
re.IGNORECASE,
)
def _logical_lines(text: str) -> str:
"""Join Dockerfile continuations so policy regexes see one instruction."""
return re.sub(r"\\\r?\n", " ", text)
def check_dockerfile(
path: Path,
text: str,
image_build_args: dict[str, str] | None = None,
) -> list[str]:
"""Return policy violations for one Dockerfile."""
problems: list[str] = []
logical = _logical_lines(text)
instructions = "\n".join(
line for line in logical.splitlines()
if not line.lstrip().startswith("#")
)
from_matches = list(re.finditer(r"(?im)^\s*FROM\s+(\S+)", logical))
from_values = [match.group(1) for match in from_matches]
preamble = logical[:from_matches[0].start()] if from_matches else logical
base_args = {
match.group(1): match.group(2)
for match in re.finditer(
r"(?im)^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)(?:=(\S+))?\s*$",
preamble,
)
}
if not from_values:
problems.append(f"{path}: missing FROM")
for value in from_values:
variable = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", value)
if variable and variable.group(1) == "ORCHESTRATOR_BASE_IMAGE":
if (
"ORCHESTRATOR_BASE_IMAGE" not in base_args
or base_args["ORCHESTRATOR_BASE_IMAGE"] is not None
):
problems.append(
f"{path}: local base argument must have no mutable default",
)
continue
if variable:
name = variable.group(1)
if name not in base_args:
problems.append(
f"{path}: dynamic base argument {name} is not declared before FROM",
)
continue
if base_args[name] is not None:
problems.append(
f"{path}: base argument {name} must not define a default",
)
continue
if image_build_args is None or name not in image_build_args:
problems.append(
f"{path}: base argument {name} lacks a centralized value",
)
continue
value = image_build_args[name]
if "$" in value:
problems.append(f"{path}: dynamic base image is not content-pinned: {value}")
continue
try:
image, digest = value.rsplit("@sha256:", 1)
except ValueError:
problems.append(f"{path}: base image lacks a sha256 digest: {value}")
continue
leaf = image.rsplit("/", 1)[-1]
if ":" not in leaf:
problems.append(f"{path}: base image lacks a version-qualified tag: {value}")
elif leaf.rsplit(":", 1)[1] == "latest":
problems.append(f"{path}: base image uses latest: {value}")
if not _DIGEST.fullmatch(digest):
problems.append(f"{path}: base image digest is not 64 lowercase hex: {value}")
if _NETWORK_TO_SHELL.search(instructions):
problems.append(f"{path}: downloads network content directly into a shell")
if re.search(r"\b(?:npm\s+(?:i|install)|pi\s+install)\b", instructions):
problems.append(f"{path}: use npm ci with a committed integrity lock")
if "apt-get install" in instructions:
if not _SNAPSHOT.search(text):
problems.append(f"{path}: apt install lacks a fixed Debian snapshot")
if "snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" not in text:
problems.append(f"{path}: apt sources are not routed to the snapshot")
if "Acquire::Check-Valid-Until=false" not in text:
problems.append(f"{path}: snapshot apt update lacks validity override")
if path == Path("Dockerfile.gateway"):
if "--require-hashes" not in instructions:
problems.append(f"{path}: gateway Python install does not require hashes")
if "requirements.gateway.lock" not in text:
problems.append(f"{path}: gateway Python lock is not consumed")
if path == Path("bot_bottle/contrib/codex/Dockerfile"):
required = (
"codex-package_SHA256SUMS",
"sha256sum -c",
"codex-package-${codex_target}.tar.gz",
)
for marker in required:
if marker not in instructions:
problems.append(f"{path}: verified pinned Codex install lacks {marker!r}")
return problems
def load_image_build_args(root: Path) -> tuple[dict[str, str], list[str]]:
"""Load and validate the one repository location for base-image args."""
path = root / IMAGE_BUILD_ARGS
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {}, [f"{IMAGE_BUILD_ARGS}: cannot read image build arguments: {exc}"]
if not isinstance(data, dict):
return {}, [f"{IMAGE_BUILD_ARGS}: must contain a JSON object"]
problems: list[str] = []
names = set(data)
missing = REQUIRED_BASE_ARGS - names
unexpected = names - REQUIRED_BASE_ARGS
if missing:
problems.append(
f"{IMAGE_BUILD_ARGS}: missing arguments: {', '.join(sorted(missing))}",
)
if unexpected:
problems.append(
f"{IMAGE_BUILD_ARGS}: unexpected arguments: "
f"{', '.join(sorted(unexpected))}",
)
for name, value in data.items():
if not isinstance(value, str):
problems.append(f"{IMAGE_BUILD_ARGS}: {name} must be a string")
return {
name: value for name, value in data.items()
if isinstance(name, str) and isinstance(value, str)
}, problems
def check_npm_manifest(root: Path, manifest_path: Path) -> list[str]:
"""Validate exact direct versions and integrity-complete npm locks."""
problems: list[str] = []
manifest_file = root / manifest_path
lock_file = manifest_file.with_name("package-lock.json")
try:
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
lock = json.loads(lock_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return [f"{manifest_path}: cannot read manifest and lock: {exc}"]
direct = manifest.get("dependencies", {})
for package, version in direct.items():
if not isinstance(version, str) or not _EXACT_NPM_VERSION.fullmatch(version):
problems.append(
f"{manifest_path}: {package} is not an exact version: {version!r}",
)
root_lock = lock.get("packages", {}).get("", {}).get("dependencies", {})
if root_lock != direct:
problems.append(f"{lock_file.relative_to(root)}: root dependencies are stale")
if lock.get("lockfileVersion") != 3:
problems.append(f"{lock_file.relative_to(root)}: require lockfileVersion 3")
for package_path, package in lock.get("packages", {}).items():
if not package_path or package.get("link"):
continue
resolved = package.get("resolved", "")
if isinstance(resolved, str) and resolved.startswith(("http://", "https://")):
integrity = package.get("integrity", "")
if not isinstance(integrity, str) or not integrity.startswith("sha512-"):
problems.append(
f"{lock_file.relative_to(root)}: "
f"{package_path} lacks sha512 integrity",
)
return problems
def check_python_lock(root: Path) -> list[str]:
"""Validate that every gateway Python requirement is exact and hashed."""
path = root / "requirements.gateway.lock"
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
return [f"requirements.gateway.lock: cannot read lock: {exc}"]
problems: list[str] = []
blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", text)
requirements = [block for block in blocks if re.match(r"^[A-Za-z0-9_.-]+==", block)]
if not requirements:
problems.append("requirements.gateway.lock: contains no exact requirements")
for block in requirements:
name = block.split("==", 1)[0]
first_line = block.splitlines()[0]
if not re.match(r"^[A-Za-z0-9_.-]+==[^\s\\]+", first_line):
problems.append(f"requirements.gateway.lock: {name} is not exact")
if not re.search(r"--hash=sha256:[0-9a-f]{64}", block):
problems.append(f"requirements.gateway.lock: {name} lacks a sha256 hash")
try:
direct = [
line.strip()
for line in (root / "requirements.gateway.in").read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
except OSError as exc:
problems.append(f"requirements.gateway.in: cannot read input: {exc}")
direct = []
for requirement in direct:
if not re.fullmatch(r"[A-Za-z0-9_.-]+==[^\s]+", requirement):
problems.append(
f"requirements.gateway.in: direct dependency is not exact: {requirement}",
)
if not re.search(rf"(?m)^{re.escape(requirement)}(?:\s|\\)", text):
problems.append(
f"requirements.gateway.lock: missing direct dependency {requirement}",
)
return problems
def check_codex_checksums(root: Path) -> list[str]:
"""Require one valid digest for every supported standalone archive."""
try:
lines = (root / CODEX_CHECKSUMS).read_text(encoding="utf-8").splitlines()
except OSError as exc:
return [f"{CODEX_CHECKSUMS}: cannot read checksum manifest: {exc}"]
assets: dict[str, str] = {}
problems: list[str] = []
for line in lines:
match = re.fullmatch(r"([0-9a-f]{64}) (\S+)", line)
if match is None:
problems.append(f"{CODEX_CHECKSUMS}: malformed checksum line: {line!r}")
continue
digest, asset = match.groups()
if asset in assets:
problems.append(f"{CODEX_CHECKSUMS}: duplicate checksum for {asset}")
assets[asset] = digest
missing = REQUIRED_CODEX_ASSETS - assets.keys()
if missing:
problems.append(
f"{CODEX_CHECKSUMS}: missing supported archives: "
f"{', '.join(sorted(missing))}",
)
return problems
def check_repo(root: Path = REPO_ROOT) -> list[str]:
"""Return every repository image-input policy violation."""
image_build_args, problems = load_image_build_args(root)
for path in DOCKERFILES:
try:
text = (root / path).read_text(encoding="utf-8")
except OSError as exc:
problems.append(f"{path}: cannot read Dockerfile: {exc}")
continue
problems.extend(check_dockerfile(path, text, image_build_args))
for manifest in NPM_MANIFESTS:
problems.extend(check_npm_manifest(root, manifest))
problems.extend(check_codex_checksums(root))
nested_path = Path(
"bot_bottle/backend/macos_container/nested_containers.py",
)
try:
nested_source = (root / nested_path).read_text(encoding="utf-8")
except OSError as exc:
problems.append(f"{nested_path}: cannot read generated image source: {exc}")
else:
if "FROM docker:" in nested_source:
problems.append(
"nested-containers image uses a mutable Docker CLI base",
)
if '"ARG DOCKER_CLI_BASE_IMAGE\\n"' not in nested_source:
problems.append(
"nested-containers image does not consume DOCKER_CLI_BASE_IMAGE",
)
if 'image = f"{pinned_base}{IMAGE_SUFFIX}"' not in nested_source:
problems.append(
"nested-containers output is not keyed by its pinned agent base",
)
problems.extend(check_python_lock(root))
return problems
def main() -> int:
problems = check_repo()
if problems:
for problem in problems:
print(f"image-input policy: {problem}", file=sys.stderr)
return 1
print("image-input policy: all supported image inputs are pinned and verified")
return 0
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/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())
+5 -5
View File
@@ -13,7 +13,7 @@
# are re-executed; no KVM or Docker dependency.
#
# Pass "critical" as the last argument in either mode to also report just the
# critical modules (ADR 0004 target: 90%).
# critical modules (ADR 0004 target: 85%).
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -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 minimum: 85%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" --fail-under=85
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 minimum: 85%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" --fail-under=85
fi
+2 -1
View File
@@ -1,4 +1,4 @@
# Critical security/logic core held to the >=90% coverage bar by
# Critical security/logic core held to the >=85% coverage bar by
# docs/decisions/0004-coverage-policy.md.
#
# SINGLE SOURCE OF TRUTH: scripts/coverage.sh (the `critical` report) and
@@ -30,6 +30,7 @@ bot_bottle/manifest/agent.py
bot_bottle/manifest/bottle.py
bot_bottle/manifest/egress.py
bot_bottle/manifest/extends.py
bot_bottle/manifest/forge.py
bot_bottle/manifest/git.py
bot_bottle/manifest/index.py
bot_bottle/manifest/loader.py
+8 -1
View File
@@ -35,5 +35,12 @@ 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.
docker build -q -f bot_bottle/contrib/claude/Dockerfile -t bot-bottle-claude:latest . >/dev/null 2>&1 || true
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 Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true
+3 -3
View File
@@ -13,8 +13,8 @@ policy.
Usage:
scripts/coverage.sh # produce .coverage first
python3 scripts/diff_coverage.py # gate against origin/main, min 90%
python3 scripts/diff_coverage.py --base main --min 85
python3 scripts/diff_coverage.py # gate against origin/main, min 80%
python3 scripts/diff_coverage.py --base main --min 75
"""
from __future__ import annotations
@@ -74,7 +74,7 @@ def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="origin/main",
help="git ref to diff against (default: origin/main)")
ap.add_argument("--min", type=float, default=90.0,
ap.add_argument("--min", type=float, default=80.0,
help="minimum %% of changed executable lines covered")
args = ap.parse_args()
+2
View File
@@ -21,9 +21,11 @@ _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",
)
+11 -1
View File
@@ -23,6 +23,7 @@ import os
import subprocess
import unittest
from bot_bottle import resources
from tests._backend import skip_unless_backend
@@ -38,9 +39,18 @@ 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, "."],
"-f", _DOCKERFILE, *arg_flags, "."],
cwd=repo_root,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
check=False,
@@ -21,7 +21,7 @@ import subprocess
import time
import unittest
from bot_bottle.backend.docker.infra_launch import (
from bot_bottle.backend.docker.consolidated_launch import (
_network_cidr,
_network_container_ips,
)
+1 -6
View File
@@ -83,13 +83,8 @@ class TestRuntimeModuleSizes(unittest.TestCase):
self.assertEqual([], violations)
def test_backend_contract_does_not_absorb_preparation_logic(self) -> None:
# base.py holds the backend *contract*; implementation lives elsewhere.
# The cap is a tripwire against absorbing preparation/impl logic, not a
# ban on new contract surface — bumped 580->600 for the PRD 0081
# gateway-attach contract (delegator + 3 abstract primitives; the flow
# itself lives in backend/gateway_attach.py, not here).
caps = {
ROOT / "bot_bottle" / "backend" / "base.py": 600,
ROOT / "bot_bottle" / "backend" / "base.py": 580,
ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
}
oversized = [
+4 -172
View File
@@ -11,11 +11,9 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.reprovision import reprovision_bottles
from bot_bottle.backend.base import BottleBackend, InfraLaunchError
from bot_bottle.backend.gateway_attach import GatewayAttachResources
from bot_bottle.backend.firecracker import infra_launch as fc
from bot_bottle.backend.macos_container import infra_launch as mac
from bot_bottle.backend.docker import infra_launch as docker
from bot_bottle.backend.firecracker import consolidated_launch as fc
from bot_bottle.backend.macos_container import consolidated_launch as mac
from bot_bottle.backend.docker import consolidated_launch as docker
from bot_bottle.orchestrator.client import OrchestratorClientError
@@ -134,7 +132,7 @@ class TestFirecrackerReprovision(unittest.TestCase):
def test_persist_failure_is_fatal_to_launch(self) -> None:
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="denied")):
with self.assertRaisesRegex(fc.InfraLaunchError, "denied"):
with self.assertRaisesRegex(fc.ConsolidatedLaunchError, "denied"):
fc.persist_env_var_secret(Path("/key"), "10.0.0.1", "secret")
def test_reads_live_vm_key_and_reprovisions(self) -> None:
@@ -161,171 +159,5 @@ class TestFirecrackerReprovision(unittest.TestCase):
restore.assert_called_once_with(client, {})
class TestAttachFlow(unittest.TestCase):
"""PRD 0081 / #519 review: the base backend owns the reconcile flow and
fails hard gather resources, attempt every running bottle, then raise an
aggregate if any failed (never silently skip)."""
def _fake(self, **primitives: object) -> SimpleNamespace:
base: dict[str, object] = {
"_gateway_attach_resources": Mock(
return_value=GatewayAttachResources(ca_pem="PEM")),
"_running_bottles": Mock(return_value=["a", "b", "c"]),
"_attach_bottle_to_gateway": Mock(),
}
base.update(primitives)
return SimpleNamespace(**base)
def test_attaches_every_running_bottle_once(self) -> None:
fake = self._fake()
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
self.assertEqual(3, fake._attach_bottle_to_gateway.call_count)
# Each bottle got the single gathered resource set.
res = fake._gateway_attach_resources.return_value
for call in fake._attach_bottle_to_gateway.call_args_list:
self.assertIs(res, call.args[1])
def test_attempts_all_then_raises_aggregate_on_failure(self) -> None:
def attach(bottle: str, _resources: object) -> None:
if bottle in ("b", "c"):
raise InfraLaunchError(f"{bottle} unreachable")
fake = self._fake(_attach_bottle_to_gateway=Mock(side_effect=attach))
with self.assertRaises(InfraLaunchError) as ctx:
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
msg = str(ctx.exception)
# Every bottle was attempted (not fail-fast), and both failures surface.
self.assertEqual(3, fake._attach_bottle_to_gateway.call_count)
self.assertIn("b unreachable", msg)
self.assertIn("c unreachable", msg)
self.assertIn("2", msg)
def test_resource_gathering_failure_fails_hard_before_enumerating(self) -> None:
fake = self._fake(
_gateway_attach_resources=Mock(side_effect=RuntimeError("no CA yet")))
with self.assertRaises(RuntimeError):
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
fake._running_bottles.assert_not_called()
class TestFirecrackerAttachPrimitive(unittest.TestCase):
"""PRD 0081: attach_ca_to_agent_vm pushes the current gateway CA into one
running agent VM over SSH, failing hard."""
def _run_dir(self, root: Path, ip: str = "10.243.0.3") -> Path:
run_dir = root / "demo"
run_dir.mkdir()
(run_dir / "bottle_id_ed25519").write_text("key")
(run_dir / "config.json").write_text(json.dumps({
"boot-source": {"boot_args": f"root=/dev/vda ip={ip}::gw:mask::eth0:off"}
}))
return run_dir
def test_pushes_ca_over_stdin_and_rebuilds_trust_store(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc()) as run:
fc.attach_ca_to_agent_vm(run_dir, "PEM-DATA")
# The cert goes over stdin (off argv), and the trust store is rebuilt.
self.assertEqual("PEM-DATA", run.call_args.kwargs["input"])
script = run.call_args.args[0][-1]
self.assertIn(fc.AGENT_CA_PATH, script)
self.assertIn("update-ca-certificates", script)
def test_malformed_run_dir_fails_hard(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
empty = Path(tmp) / "no-config"
empty.mkdir()
with self.assertRaises(fc.InfraLaunchError):
fc.attach_ca_to_agent_vm(empty, "PEM")
def test_push_failure_fails_hard(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="down")):
with self.assertRaisesRegex(fc.InfraLaunchError, "down"):
fc.attach_ca_to_agent_vm(run_dir, "PEM")
class TestDockerAttachPrimitives(unittest.TestCase):
def test_running_agent_containers_excludes_gateway(self) -> None:
# network inspect lists the gateway (excluded) + one agent + a blank line.
inspect = _proc(stdout="bot-bottle-orch-gateway\nbot-bottle-a\n\n")
with patch.object(docker, "run_docker", return_value=inspect):
names = docker.running_agent_containers(
network="net", gateway_name="bot-bottle-orch-gateway")
self.assertEqual(["bot-bottle-a"], names)
def test_running_agent_containers_fails_hard_on_docker_error(self) -> None:
with patch.object(docker, "run_docker", side_effect=FileNotFoundError("docker")):
with self.assertRaises(FileNotFoundError):
docker.running_agent_containers()
def test_push_ca_cp_then_rebuilds_trust_store(self) -> None:
with patch.object(
docker, "run_docker", side_effect=[_proc(), _proc(), _proc()],
) as run:
docker.push_ca_to_container("bot-bottle-a", "PEM")
argvs = [c.args[0] for c in run.call_args_list]
cp = next(a for a in argvs if a[:2] == ["docker", "cp"])
self.assertEqual(f"bot-bottle-a:{docker.AGENT_CA_PATH}", cp[-1])
self.assertTrue(any("update-ca-certificates" in a[-1] for a in argvs))
def test_push_ca_fails_hard(self) -> None:
with patch.object(docker, "run_docker", return_value=_proc(1, stderr="gone")):
with self.assertRaisesRegex(docker.InfraLaunchError, "gone"):
docker.push_ca_to_container("bot-bottle-a", "PEM")
def test_gateway_attach_resources_reads_the_threaded_infra_gateway(self) -> None:
# On the bring-up reconcile path the backend is threaded with the infra
# whose gateway just cold-booted, so it reads THAT gateway's CA — not a
# fresh default-named DockerInfraService that wouldn't exist for a
# non-default instance (#519 review).
from bot_bottle.backend.docker.backend import DockerBottleBackend
infra = Mock()
infra.gateway.return_value.ca_cert_pem.return_value = "ITEST-CA"
res = DockerBottleBackend(infra=infra)._gateway_attach_resources()
self.assertEqual("ITEST-CA", res.ca_pem)
infra.gateway.assert_called_once()
def test_gateway_attach_resources_defaults_to_the_host_singleton(self) -> None:
# Ordinary construction (no infra) falls back to the per-host default.
from bot_bottle.backend.docker.backend import DockerBottleBackend
with patch("bot_bottle.backend.docker.infra.DockerInfraService") as InfraCls:
InfraCls.return_value.gateway.return_value.ca_cert_pem.return_value = "DEFAULT-CA"
res = DockerBottleBackend()._gateway_attach_resources()
InfraCls.assert_called_once_with()
self.assertEqual("DEFAULT-CA", res.ca_pem)
class TestMacosAttachPrimitives(unittest.TestCase):
def test_running_agent_containers_from_enumeration(self) -> None:
with patch.object(mac, "enumerate_active",
return_value=[SimpleNamespace(slug="demo")]):
names = mac.running_agent_containers()
self.assertEqual([f"{mac.CONTAINER_NAME_PREFIX}demo"], names)
def test_running_agent_containers_fails_hard(self) -> None:
with patch.object(mac, "enumerate_active",
side_effect=mac.EnumerationError("failed")):
with self.assertRaises(mac.EnumerationError):
mac.running_agent_containers()
def test_push_ca_cp_then_rebuilds_trust_store(self) -> None:
with patch.object(mac.container_mod, "run_container_argv",
return_value=_proc()) as run:
mac.push_ca_to_container(f"{mac.CONTAINER_NAME_PREFIX}demo", "PEM")
argvs = [c.args[0] for c in run.call_args_list]
self.assertTrue(any(mac.AGENT_CA_PATH in " ".join(a) for a in argvs))
self.assertTrue(any("update-ca-certificates" in a[-1] for a in argvs))
def test_push_ca_fails_hard(self) -> None:
with patch.object(mac.container_mod, "run_container_argv",
return_value=_proc(1, stderr="no")):
with self.assertRaisesRegex(mac.InfraLaunchError, "no"):
mac.push_ca_to_container("x", "PEM")
if __name__ == "__main__":
unittest.main()
+25 -6
View File
@@ -2,24 +2,43 @@
from __future__ import annotations
import json
import re
import unittest
from pathlib import Path
_CONTRIB_DIR = Path(__file__).resolve().parents[2] / "bot_bottle/contrib"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CONTRIB_DIR = _REPO_ROOT / "bot_bottle/contrib"
_AGENT_DOCKERFILES = tuple(sorted(_CONTRIB_DIR.glob("*/Dockerfile")))
class TestBuiltinAgentImages(unittest.TestCase):
def test_all_use_debian_trixie_stable(self):
def test_all_share_one_digest_pinned_node_trixie_base(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):
self.assertRegex(
dockerfile.read_text(),
r"(?m)^FROM node:22-trixie-slim\s*$",
)
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)
def test_none_install_podman(self):
# podman lives in the nested-containers derived layer (nested_containers.py),
@@ -0,0 +1,98 @@
"""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()
@@ -6,7 +6,7 @@ import unittest
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from bot_bottle.backend.docker.infra_launch import (
from bot_bottle.backend.docker.consolidated_launch import (
launch_consolidated,
deprovision_consolidated,
)
@@ -14,7 +14,7 @@ from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.orchestrator.client import RegisteredBottle
_MOD = "bot_bottle.backend.docker.infra_launch"
_MOD = "bot_bottle.backend.docker.consolidated_launch"
_UTIL = "bot_bottle.backend.provision_bottle"
+8 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -21,6 +22,7 @@ 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:
@@ -209,9 +211,13 @@ class TestPiSuperviseMcp(unittest.TestCase):
class TestPiDockerfile(unittest.TestCase):
def test_installs_pi_cwd_at_build_time(self):
def test_installs_exact_pi_cwd_from_the_committed_lock(self):
dockerfile = _PI_DOCKERFILE.read_text()
self.assertIn("pi install npm:@harms-haus/pi-cwd", dockerfile)
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)
def test_prepares_pi_extension_state_dirs_and_tmp_for_node(self):
dockerfile = _PI_DOCKERFILE.read_text()
-24
View File
@@ -29,20 +29,10 @@ class TestDockerInfraService(unittest.TestCase):
self.orch.gateway_url.return_value = f"http://{ORCHESTRATOR_NAME}:8099"
self.orch.mint_gateway_token.return_value = "gw.jwt"
self.gw = MagicMock()
# Default to a cold boot (gateway (re)created) so the reconcile branch
# runs; the actual reconcile is stubbed so these composition tests stay
# isolated from docker.
self.gw.connect_to_orchestrator.return_value = True
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
p = patch.object(self.svc, name, return_value=mock)
p.start()
self.addCleanup(p.stop)
ap = patch(
"bot_bottle.backend.docker.backend.DockerBottleBackend"
".attach_bottled_agents_to_gateway"
)
self.attach = ap.start()
self.addCleanup(ap.stop)
def test_url_delegates_to_the_orchestrator(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.svc.url())
@@ -68,20 +58,6 @@ class TestDockerInfraService(unittest.TestCase):
f"http://{ORCHESTRATOR_NAME}:8099", "gw.jwt")
self.orch.mint_gateway_token.assert_called_once()
def test_cold_boot_reconciles_running_bottles(self) -> None:
# A (re)created gateway (connect returns True) triggers the bring-up
# reconcile so running bottles re-trust the fresh gateway (PRD 0081).
self.gw.connect_to_orchestrator.return_value = True
self.svc.ensure_running()
self.attach.assert_called_once_with()
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
# A healthy current gateway (connect returns False) is not a cold boot,
# so there is nothing to reconcile.
self.gw.connect_to_orchestrator.return_value = False
self.svc.ensure_running()
self.attach.assert_not_called()
def test_stop_removes_both_containers(self) -> None:
with patch(_RUN) as run:
self.svc.stop()
@@ -16,7 +16,7 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.infra_launch import LaunchContext
from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.log import Die
+1 -1
View File
@@ -19,7 +19,7 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleImages, BottleSpec
from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.infra_launch import LaunchContext
from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex
+4
View File
@@ -221,6 +221,10 @@ 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")):
+29 -4
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock
@@ -21,7 +22,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex
from bot_bottle.manifest import ManifestGitUser, ManifestIndex
class _Provider(AgentProvider):
@@ -50,15 +51,39 @@ def _plan(*, git_user: dict | None = None, # type: ignore
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} # type: ignore
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
if git_repos is not None:
bottle_json.setdefault("git-gate", {})["repos"] = git_repos
# Identity now lives on the agent's `author` block; at composition it
# populates manifest.bottle.git_user, which provision_git reads
# (production unchanged). When the caller passes a full name+email we
# route it through `author`; the name-only / email-only cases (which
# exercise provision_git emitting a single `git config` line) can't be
# expressed via `author` (both fields required), so we inject the
# partial ManifestGitUser onto the composed bottle directly.
agent_json: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
full_author = (
git_user
if git_user and git_user.get("name") and git_user.get("email")
else None
)
if full_author is not None:
agent_json["author"] = full_author
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
"agents": {"demo": agent_json},
})
manifest = index.load_for_agent("demo")
if git_user is not None and full_author is None:
manifest = replace(
manifest,
bottle=replace(
manifest.bottle,
git_user=ManifestGitUser(
name=git_user.get("name", ""),
email=git_user.get("email", ""),
),
),
)
spec = BottleSpec(
manifest=index, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd,
+143 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import subprocess
import unittest
from datetime import timezone
from unittest.mock import patch
from unittest.mock import call, patch
from bot_bottle.backend.docker import util as docker_mod
@@ -121,5 +121,147 @@ 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()
+1 -4
View File
@@ -61,10 +61,7 @@ class TestFirecrackerGatewayConnect(unittest.TestCase):
booted = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))
with patch.object(infra_vm, "boot_vm", return_value=booted) as boot, \
patch.object(infra_vm, "push_secret") as push:
cold_booted = gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
# The VM is booted fresh here (cold-boot path only), so it always
# signals a cold boot → the caller reconciles running bottles (PRD 0081).
self.assertTrue(cold_booted)
gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
# Booted on the gateway link with the gateway role; the orchestrator's
# guest IP (parsed off the URL) rides the cmdline as bb_orch.
kw = boot.call_args.kwargs
@@ -75,8 +75,39 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
other.write_text("FROM python:3.12-slim\n")
self.assertNotEqual(base, image_builder._rootfs_digest(other))
def test_rootfs_digest_tracks_centralized_build_args(self):
with patch.object(
image_builder.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:first"},
):
first = image_builder._rootfs_digest(self.dockerfile)
with patch.object(
image_builder.resources,
"image_build_args",
return_value={"NODE_BASE_IMAGE": "node:second"},
):
second = image_builder._rootfs_digest(self.dockerfile)
self.assertNotEqual(first, second)
class TestSmokeTest(unittest.TestCase):
def test_buildah_receives_centralized_image_build_args(self):
with patch.object(
image_builder,
"_ssh_streamed",
return_value=0,
) as ssh, patch.object(image_builder, "info"):
image_builder._buildah_build(
Path("/k"),
"10.0.0.1",
"/tmp/context",
"agent:test",
{"NODE_BASE_IMAGE": "node:pinned"},
)
script = ssh.call_args.args[2]
self.assertIn("--build-arg NODE_BASE_IMAGE=node:pinned", script)
def test_empty_argv_is_noop(self):
with patch.object(image_builder, "_ssh") as ssh:
image_builder._smoke_test(Path("/k"), "10.0.0.1", "tag", "ctr", ())
-18
View File
@@ -40,21 +40,7 @@ class TestAccessors(unittest.TestCase):
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", svc.url())
_ATTACH = (
"bot_bottle.backend.firecracker.backend.FirecrackerBottleBackend"
".attach_bottled_agents_to_gateway"
)
class TestEnsureRunning(unittest.TestCase):
def setUp(self) -> None:
# The cold-boot branch reconciles running bottles against the fresh
# gateway (PRD 0081). Stub it so these composition tests stay isolated
# from SSH; the wiring itself is asserted in test_cold_boot_reconciles*.
ap = patch(_ATTACH)
self.attach = ap.start()
self.addCleanup(ap.stop)
def test_adopts_when_healthy_alive_and_version_matches(self):
# Healthy control plane + live gateway + existing key + matching version
# marker -> adopt (no stop/build/boot); returns the orchestrator URL.
@@ -73,8 +59,6 @@ class TestEnsureRunning(unittest.TestCase):
built.assert_not_called()
ip = infra_vm.netpool.orch_slot().guest_ip
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", url)
# Adopt path — no cold boot, so no running-bottle reconcile.
self.attach.assert_not_called()
def test_reboots_both_when_version_stale(self):
# Healthy control plane but the running pair booted an OLDER image
@@ -136,8 +120,6 @@ class TestEnsureRunning(unittest.TestCase):
# reach the control plane at startup).
orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
# A cold boot minted a fresh gateway CA — reconcile running bottles.
self.attach.assert_called_once_with()
if __name__ == "__main__":
+14 -1
View File
@@ -107,7 +107,12 @@ class TestEnsureBuilt(unittest.TestCase):
def test_local_mode_builds_orchestrator_before_its_fc_image(self):
with patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": "local"}), \
patch.object(infra_vm.docker_mod, "build_image") as build:
patch.object(infra_vm.docker_mod, "build_image") as build, \
patch.object(
infra_vm.docker_mod,
"pinned_local_image_ref",
return_value="bot-bottle-orchestrator:sha256-" + "a" * 64,
) as pinned_ref:
infra_vm.ensure_built()
tags = [c.args[0] for c in build.call_args_list]
# orchestrator-fc is FROM orchestrator, so the base is built first.
@@ -116,6 +121,14 @@ class TestEnsureBuilt(unittest.TestCase):
self.assertEqual(infra_vm._ORCHESTRATOR_FC_IMAGE, tags[-1])
self.assertLess(tags.index(infra_vm._ORCHESTRATOR_IMAGE),
tags.index(infra_vm._ORCHESTRATOR_FC_IMAGE))
pinned_ref.assert_called_once_with(infra_vm._ORCHESTRATOR_IMAGE)
self.assertEqual(
{
"ORCHESTRATOR_BASE_IMAGE":
"bot-bottle-orchestrator:sha256-" + "a" * 64,
},
build.call_args_list[-1].kwargs["build_args"],
)
class TestStop(unittest.TestCase):
+426
View File
@@ -0,0 +1,426 @@
"""Unit: trusted agent forge identity & guidance
(PRD 0082).
Covers the net-new surface: agent `author`, agent `forge-accounts` (Gitea API
URL validation + host token reference), the repoforge association resolved at
composition, the synthesized proxy-held egress route, and the generated,
non-secret prompt guidance. Legacy git-gate.user / cwd-agent behavior lives in
the manifest test modules.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from typing import Callable
from unittest.mock import patch
from bot_bottle.egress import (
egress_forge_routes,
egress_render_routes,
egress_resolve_token_values,
egress_routes_for_bottle,
egress_token_env_map,
)
from bot_bottle.manifest import ManifestError, ManifestIndex
from bot_bottle.manifest.forge import (
ManifestForgeAccount,
canonicalize_forge_url,
render_forge_guidance,
)
GITEA = {
"url": "https://gitea.dideric.is/api/v1",
"auth": {"type": "token", "token_secret": "GITEA_CLAUDE_TOKEN"},
}
def _repo(*, forge: str | None = None) -> dict[str, object]:
entry: dict[str, object] = {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/k"},
}
if forge is not None:
entry["forge"] = forge
return entry
def _index(
*,
author: dict[str, object] | None = None,
forge_accounts: dict[str, object] | None = None,
repo_forge: str | None = None,
) -> ManifestIndex:
"""Build an eager index with one agent 'claude' + bottle 'bb'."""
agent: dict[str, object] = {"bottle": "bb", "prompt": ""}
if author is not None:
agent["author"] = author
if forge_accounts is not None:
agent["forge-accounts"] = forge_accounts
bottle: dict[str, object] = {
"git-gate": {"repos": {"bot-bottle": _repo(forge=repo_forge)}}
}
return ManifestIndex.from_json_obj(
{"bottles": {"bb": bottle}, "agents": {"claude": agent}}
)
def _error(
callable_: Callable[..., object], *args: object, **kwargs: object,
) -> str:
try:
callable_(*args, **kwargs)
except ManifestError as e:
return str(e)
raise AssertionError("expected ManifestError was not raised")
# ---------------------------------------------------------------------------
# author
# ---------------------------------------------------------------------------
class TestAuthor(unittest.TestCase):
def test_populates_git_identity(self) -> None:
idx = _index(author={"name": "didericis-claude", "email": "e+c@x.is"})
m = idx.load_for_agent("claude", ())
self.assertEqual("didericis-claude", m.bottle.git_user.name)
self.assertEqual("e+c@x.is", m.bottle.git_user.email)
self.assertEqual(
"name=didericis-claude, email=e+c@x.is",
m.git_identity_summary(),
)
def test_absent_author_no_identity(self) -> None:
m = _index().load_for_agent("claude", ())
self.assertTrue(m.bottle.git_user.is_empty())
self.assertIsNone(m.git_identity_summary())
def test_name_required(self) -> None:
msg = _error(_index, author={"email": "e@x.is"})
self.assertIn("author.name must be a non-empty string", msg)
def test_email_required(self) -> None:
msg = _error(_index, author={"name": "n"})
self.assertIn("author.email must be a non-empty string", msg)
def test_email_rejects_whitespace(self) -> None:
msg = _error(_index, author={"name": "n", "email": "a b@x.is"})
self.assertIn("must not contain whitespace", msg)
def test_name_rejects_newline(self) -> None:
msg = _error(_index, author={"name": "a\nb", "email": "e@x.is"})
self.assertIn("author.name must not contain newlines", msg)
def test_unknown_key(self) -> None:
msg = _error(
_index, author={"name": "n", "email": "e@x.is", "role": "x"}
)
self.assertIn("author has unknown key", msg)
# ---------------------------------------------------------------------------
# forge-accounts URL validation
# ---------------------------------------------------------------------------
class TestForgeUrl(unittest.TestCase):
def test_canonical_ok(self) -> None:
canonical, origin, host, prefix = canonicalize_forge_url(
"a", "g", "https://Gitea.Dideric.is/api/v1/"
)
# trailing slash normalized; host lowercased.
self.assertEqual("https://gitea.dideric.is/api/v1", canonical)
self.assertEqual("https://gitea.dideric.is", origin)
self.assertEqual("gitea.dideric.is", host)
self.assertEqual("/api/v1", prefix)
def test_port_preserved(self) -> None:
canonical, origin, _host, _ = canonicalize_forge_url(
"a", "g", "https://gitea.local:3000/api/v1"
)
self.assertEqual("https://gitea.local:3000/api/v1", canonical)
self.assertEqual("https://gitea.local:3000", origin)
def test_http_fails(self) -> None:
self.assertIn("must use https", _error(
canonicalize_forge_url, "a", "g", "http://gitea/api/v1"))
def test_userinfo_fails(self) -> None:
self.assertIn("userinfo", _error(
canonicalize_forge_url, "a", "g", "https://u:p@gitea/api/v1"))
def test_query_fails(self) -> None:
self.assertIn("query string", _error(
canonicalize_forge_url, "a", "g", "https://gitea/api/v1?x=1"))
def test_fragment_fails(self) -> None:
self.assertIn("fragment", _error(
canonicalize_forge_url, "a", "g", "https://gitea/api/v1#x"))
def test_missing_host_fails(self) -> None:
self.assertIn("hostname", _error(
canonicalize_forge_url, "a", "g", "https:///api/v1"))
def test_bad_path_fails(self) -> None:
self.assertIn("Gitea API base", _error(
canonicalize_forge_url, "a", "g", "https://gitea/api/v2"))
def test_non_string_fails(self) -> None:
self.assertIn("required", _error(
canonicalize_forge_url, "a", "g", None))
def test_malformed_url_fails(self) -> None:
# An unparseable URL (invalid IPv6 literal) trips urlsplit's ValueError.
self.assertIn("not a valid URL", _error(
canonicalize_forge_url, "a", "g", "https://[/api/v1"))
class TestForgeAccount(unittest.TestCase):
def test_parses(self) -> None:
acct = ManifestForgeAccount.from_dict("a", "didericis-gitea", GITEA)
self.assertEqual("didericis-gitea", acct.alias)
self.assertEqual("gitea.dideric.is", acct.host)
self.assertEqual("token", acct.auth_type)
self.assertEqual("GITEA_CLAUDE_TOKEN", acct.token_secret)
def test_non_kebab_alias_fails(self) -> None:
self.assertIn("valid alias", _error(
ManifestForgeAccount.from_dict, "a", "Bad_Alias", GITEA))
def test_auth_required(self) -> None:
self.assertIn("missing required 'auth'", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1"}))
def test_bad_auth_type_fails(self) -> None:
self.assertIn("auth.type must be", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1",
"auth": {"type": "basic", "token_secret": "T"}}))
def test_token_secret_required(self) -> None:
self.assertIn("token_secret must be", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1", "auth": {"type": "token"}}))
def test_unknown_key_fails(self) -> None:
self.assertIn("unknown key", _error(
ManifestForgeAccount.from_dict, "a", "g",
{**GITEA, "bogus": 1}))
def test_unknown_auth_key_fails(self) -> None:
self.assertIn("auth has unknown key", _error(
ManifestForgeAccount.from_dict, "a", "g",
{"url": "https://gitea/api/v1",
"auth": {"type": "token", "token_secret": "T", "bogus": 1}}))
# ---------------------------------------------------------------------------
# repo -> forge association (composition)
# ---------------------------------------------------------------------------
class TestForgeAssociations(unittest.TestCase):
def test_resolves_when_repo_references_alias(self) -> None:
idx = _index(
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
m = idx.load_for_agent("claude", ())
self.assertEqual(1, len(m.forge_associations))
assoc = m.forge_associations[0]
self.assertEqual("didericis-gitea", assoc.alias)
self.assertEqual(("bot-bottle",), assoc.repo_names)
def test_unreferenced_account_yields_no_association(self) -> None:
idx = _index(forge_accounts={"didericis-gitea": GITEA}) # repo has no forge
m = idx.load_for_agent("claude", ())
self.assertEqual((), m.forge_associations)
def test_unknown_alias_fails_closed(self) -> None:
idx = _index(
forge_accounts={"didericis-gitea": GITEA}, repo_forge="nope"
)
msg = _error(idx.load_for_agent, "claude", ())
self.assertIn("references forge alias 'nope'", msg)
self.assertIn("not defined on agent", msg)
def test_forge_without_account_fails_closed(self) -> None:
idx = _index(repo_forge="didericis-gitea") # no forge-accounts at all
self.assertIn("(none)", _error(idx.load_for_agent, "claude", ()))
def _two_alias_index(self, t1: str, t2: str) -> ManifestIndex:
"""Two aliases on the SAME host, each referenced by a distinct repo."""
def acct(token: str) -> dict[str, object]:
return {
"url": "https://same.example/api/v1",
"auth": {"type": "token", "token_secret": token},
}
return ManifestIndex.from_json_obj({
"bottles": {"bb": {"git-gate": {"repos": {
"r1": {"url": "ssh://git@h/x/r1.git",
"key": {"provider": "static", "path": "/k"},
"forge": "g1"},
"r2": {"url": "ssh://git@h/x/r2.git",
"key": {"provider": "static", "path": "/k"},
"forge": "g2"},
}}}},
"agents": {"claude": {"bottle": "bb", "prompt": "",
"forge-accounts": {"g1": acct(t1),
"g2": acct(t2)}}},
})
def test_conflicting_aliases_same_host_fail_closed(self) -> None:
# Two aliases on the same host with DIFFERENT tokens is ambiguous —
# the proxy routes by host, so it must fail before launch rather than
# silently authenticate every call as one account.
idx = self._two_alias_index("FIRST_TOKEN", "SECOND_TOKEN")
msg = _error(idx.load_for_agent, "claude", ())
self.assertIn("both resolve to host 'same.example'", msg)
self.assertIn("ambiguous", msg)
def test_matching_aliases_same_host_ok(self) -> None:
# Identical url/auth under two aliases is unambiguous: one route.
idx = self._two_alias_index("SAME_TOKEN", "SAME_TOKEN")
m = idx.load_for_agent("claude", ())
self.assertEqual(2, len(m.forge_associations)) # both referenced
routes = egress_forge_routes(m.forge_associations)
self.assertEqual(1, len(routes)) # deduped to a single proxy route
self.assertEqual("SAME_TOKEN", routes[0].token_ref)
# ---------------------------------------------------------------------------
# synthesized egress route (proxy-held credential)
# ---------------------------------------------------------------------------
class TestForgeEgressRoutes(unittest.TestCase):
def _assoc(self):
idx = _index(
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
return idx.load_for_agent("claude", ())
def test_route_shape(self) -> None:
routes = egress_forge_routes(self._assoc().forge_associations)
self.assertEqual(1, len(routes))
r = routes[0]
self.assertEqual("gitea.dideric.is", r.host)
self.assertEqual("token", r.auth_scheme)
self.assertEqual("GITEA_CLAUDE_TOKEN", r.token_ref)
self.assertTrue(r.inspect)
# scoped to the API prefix.
self.assertEqual("/api/v1", r.matches[0].paths[0].value)
def test_token_slot_and_resolution(self) -> None:
m = self._assoc()
forge_routes = egress_forge_routes(m.forge_associations)
routes = egress_routes_for_bottle(m.bottle, (), forge_routes)
token_map = egress_token_env_map(routes)
# one slot mapping the proxy env slot -> host env var name.
self.assertEqual({"EGRESS_TOKEN_0": "GITEA_CLAUDE_TOKEN"}, token_map)
resolved = egress_resolve_token_values(
token_map, {"GITEA_CLAUDE_TOKEN": "sekret-value"}
)
self.assertEqual({"EGRESS_TOKEN_0": "sekret-value"}, resolved)
def test_rendered_routes_hide_secret_name_and_value(self) -> None:
m = self._assoc()
routes = egress_routes_for_bottle(
m.bottle, (), egress_forge_routes(m.forge_associations)
)
rendered = egress_render_routes(routes)
self.assertIn("gitea.dideric.is", rendered)
self.assertIn("EGRESS_TOKEN_0", rendered) # slot, not the host var name
self.assertNotIn("GITEA_CLAUDE_TOKEN", rendered)
self.assertNotIn("sekret-value", rendered)
def test_dedup_by_host(self) -> None:
# Two repos referencing the same alias share one route/credential.
idx = ManifestIndex.from_json_obj({
"bottles": {"bb": {"git-gate": {"repos": {
"r1": _repo(forge="g"),
"r2": {
"url": "ssh://git@h/x/other.git",
"key": {"provider": "static", "path": "/k"},
"forge": "g",
},
}}}},
"agents": {"claude": {"bottle": "bb", "prompt": "",
"forge-accounts": {"g": GITEA}}},
})
m = idx.load_for_agent("claude", ())
routes = egress_forge_routes(m.forge_associations)
self.assertEqual(1, len(routes))
# ---------------------------------------------------------------------------
# generated prompt guidance
# ---------------------------------------------------------------------------
class TestForgeGuidance(unittest.TestCase):
def _assoc(self):
idx = _index(
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
return idx.load_for_agent("claude", ()).forge_associations
def test_empty_when_no_associations(self) -> None:
self.assertEqual("", render_forge_guidance(()))
def test_names_account_repo_and_workflow(self) -> None:
text = render_forge_guidance(self._assoc())
self.assertIn("didericis-gitea", text)
self.assertIn("https://gitea.dideric.is/api/v1", text)
self.assertIn("bot-bottle", text)
# branch-backed PR workflow + AGit prohibition.
self.assertIn("refs/heads/", text)
self.assertIn("refs/for/*", text)
self.assertIn("pull request", text)
def test_no_token_secret_name_or_value(self) -> None:
text = render_forge_guidance(self._assoc())
self.assertNotIn("GITEA_CLAUDE_TOKEN", text)
def test_prompt_file_appends_guidance(self) -> None:
from bot_bottle.backend.resolve_common import prepare_agent_state_dir
idx = _index(
author={"name": "n", "email": "e@x.is"},
forge_accounts={"didericis-gitea": GITEA},
repo_forge="didericis-gitea",
)
# Give the agent a real prompt body.
m = idx.load_for_agent("claude", ())
from dataclasses import replace
m = replace(m, agent=replace(m.agent, prompt="BASE PROMPT"))
with tempfile.TemporaryDirectory() as td:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}):
_dir, prompt_file = prepare_agent_state_dir("slug1", m)
body = Path(prompt_file).read_text()
self.assertIn("BASE PROMPT", body)
self.assertIn("Forge access", body)
self.assertIn("https://gitea.dideric.is/api/v1", body)
def test_prompt_file_no_guidance_without_forge(self) -> None:
from bot_bottle.backend.resolve_common import prepare_agent_state_dir
m = _index(author={"name": "n", "email": "e@x.is"}).load_for_agent(
"claude", ()
)
with tempfile.TemporaryDirectory() as td:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}):
_dir, prompt_file = prepare_agent_state_dir("slug2", m)
body = Path(prompt_file).read_text()
self.assertNotIn("Forge access", body)
if __name__ == "__main__":
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
"""Unit tests for the immutable container-build input policy."""
from __future__ import annotations
import json
import io
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
from scripts import check_image_inputs as policy
class TestDockerfilePolicy(unittest.TestCase):
_NODE_BASE = {
"NODE_BASE_IMAGE":
"node:22.23.1-trixie-slim@sha256:" + "a" * 64,
}
def test_accepts_versioned_digest_base(self):
text = "FROM node:22.23.1-trixie-slim@sha256:" + "a" * 64 + "\n"
self.assertEqual([], policy.check_dockerfile(Path("Dockerfile"), text))
def test_accepts_defaultless_base_argument_with_centralized_value(self):
text = "ARG NODE_BASE_IMAGE\nFROM ${NODE_BASE_IMAGE}\n"
self.assertEqual(
[],
policy.check_dockerfile(
Path("Dockerfile"),
text,
self._NODE_BASE,
),
)
def test_rejects_mutable_and_latest_bases(self):
for value in (
"node:22-trixie-slim",
"node:latest@sha256:" + "a" * 64,
"node@sha256:" + "a" * 64,
):
with self.subTest(value=value):
problems = policy.check_dockerfile(
Path("Dockerfile"), f"FROM {value}\n",
)
self.assertTrue(problems)
def test_rejects_network_pipe_to_shell(self):
text = (
"FROM node:22.23.1@sha256:" + "a" * 64
+ "\nRUN curl -fsSL https://example.invalid/install.sh | sh\n"
)
problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any("directly into a shell" in item for item in problems))
def test_rejects_unlocked_npm_and_pi_installs(self):
base = "FROM node:22.23.1@sha256:" + "a" * 64 + "\nRUN "
for install in (
"npm install -g package@1.2.3",
"pi install npm:extension@1.2.3",
):
with self.subTest(install=install):
problems = policy.check_dockerfile(
Path("Dockerfile"), base + install + "\n",
)
self.assertTrue(any("npm ci" in item for item in problems))
def test_requires_snapshot_for_apt(self):
text = (
"FROM node:22.23.1@sha256:" + "a" * 64
+ "\nRUN apt-get update && apt-get install git\n"
)
problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any("fixed Debian snapshot" in item for item in problems))
def test_rejects_missing_dynamic_and_malformed_bases(self):
cases = (
("RUN true\n", "missing FROM"),
("FROM ${BASE_IMAGE}\n", "not declared before FROM"),
(
"ARG BASE_IMAGE=node:latest\nFROM ${BASE_IMAGE}\n",
"must not define a default",
),
(
"ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\n",
"lacks a centralized value",
),
(
"ARG ORCHESTRATOR_BASE_IMAGE=base:latest\n"
"FROM ${ORCHESTRATOR_BASE_IMAGE}\n",
"mutable default",
),
("FROM node:22@sha256:abc\n", "64 lowercase hex"),
)
for text, expected in cases:
with self.subTest(expected=expected):
problems = policy.check_dockerfile(Path("Dockerfile"), text)
self.assertTrue(any(expected in item for item in problems))
def test_rejects_mutable_centralized_base_value(self):
text = "ARG NODE_BASE_IMAGE\nFROM ${NODE_BASE_IMAGE}\n"
problems = policy.check_dockerfile(
Path("Dockerfile"),
text,
{"NODE_BASE_IMAGE": "node:latest"},
)
self.assertTrue(any("lacks a sha256 digest" in item for item in problems))
def test_requires_gateway_lock_and_verified_codex_markers(self):
base = "FROM node:22.23.1@sha256:" + "a" * 64 + "\n"
gateway = policy.check_dockerfile(Path("Dockerfile.gateway"), base)
self.assertTrue(any("require hashes" in item for item in gateway))
self.assertTrue(any("lock is not consumed" in item for item in gateway))
codex = policy.check_dockerfile(
Path("bot_bottle/contrib/codex/Dockerfile"),
base,
)
self.assertEqual(3, len(codex))
self.assertTrue(all("verified pinned Codex install" in item for item in codex))
class TestNpmLockPolicy(unittest.TestCase):
def _write(self, root: Path, *, version: str, integrity: str = "") -> Path:
package_dir = root / "provider"
package_dir.mkdir()
manifest = {
"name": "test",
"private": True,
"dependencies": {"example": version},
}
package = {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/example/-/example-1.2.3.tgz",
}
if integrity:
package["integrity"] = integrity
lock = {
"name": "test",
"lockfileVersion": 3,
"packages": {
"": {"dependencies": {"example": version}},
"node_modules/example": package,
},
}
(package_dir / "package.json").write_text(json.dumps(manifest))
(package_dir / "package-lock.json").write_text(json.dumps(lock))
return Path("provider/package.json")
def test_accepts_exact_integrity_locked_package(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = self._write(root, version="1.2.3", integrity="sha512-abc")
self.assertEqual([], policy.check_npm_manifest(root, path))
def test_rejects_range_and_missing_integrity(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = self._write(root, version="^1.2.3")
problems = policy.check_npm_manifest(root, path)
self.assertTrue(any("not an exact version" in item for item in problems))
self.assertTrue(any("lacks sha512 integrity" in item for item in problems))
def test_rejects_unreadable_stale_or_old_lock(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
missing = policy.check_npm_manifest(root, Path("missing/package.json"))
self.assertTrue(any("cannot read" in item for item in missing))
path = self._write(root, version="1.2.3", integrity="sha512-abc")
lock_path = root / path.with_name("package-lock.json")
lock = json.loads(lock_path.read_text())
lock["lockfileVersion"] = 2
lock["packages"][""]["dependencies"] = {}
lock_path.write_text(json.dumps(lock))
problems = policy.check_npm_manifest(root, path)
self.assertTrue(any("root dependencies are stale" in item for item in problems))
self.assertTrue(any("lockfileVersion 3" in item for item in problems))
class TestPythonLockPolicy(unittest.TestCase):
def test_rejects_missing_or_empty_lock(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
missing = policy.check_python_lock(root)
self.assertTrue(any("cannot read lock" in item for item in missing))
(root / "requirements.gateway.lock").write_text("# empty\n")
problems = policy.check_python_lock(root)
self.assertTrue(any("contains no exact requirements" in item for item in problems))
self.assertTrue(any("cannot read input" in item for item in problems))
def test_rejects_unhashed_and_stale_direct_requirements(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "requirements.gateway.lock").write_text(
"example== \\\n"
" # deliberately malformed and unhashed\n"
)
(root / "requirements.gateway.in").write_text(
"example>=1\n"
"missing==2.0.0\n"
)
problems = policy.check_python_lock(root)
self.assertTrue(any("example is not exact" in item for item in problems))
self.assertTrue(any("example lacks a sha256 hash" in item for item in problems))
self.assertTrue(any("direct dependency is not exact" in item for item in problems))
self.assertTrue(any("missing direct dependency" in item for item in problems))
class TestRepositoryPolicy(unittest.TestCase):
def test_repository_image_inputs_pass(self):
self.assertEqual([], policy.check_repo())
def test_missing_repository_inputs_are_reported(self):
with tempfile.TemporaryDirectory() as directory, patch.object(
policy,
"DOCKERFILES",
(Path("missing.Dockerfile"),),
), patch.object(policy, "NPM_MANIFESTS", ()):
problems = policy.check_repo(Path(directory))
self.assertTrue(any("cannot read Dockerfile" in item for item in problems))
def test_main_reports_success_and_failure(self):
with patch.object(policy, "check_repo", return_value=[]), redirect_stdout(io.StringIO()):
self.assertEqual(0, policy.main())
stderr = io.StringIO()
with patch.object(
policy,
"check_repo",
return_value=["bad base"],
), redirect_stderr(stderr):
self.assertEqual(1, policy.main())
self.assertIn("bad base", stderr.getvalue())
if __name__ == "__main__":
unittest.main()

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