Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 605146d287 | |||
| 27dea58ae1 | |||
| 5401f036a9 | |||
| 238f5f7614 |
@@ -212,6 +212,89 @@ jobs:
|
|||||||
name: firecracker-inputs
|
name: firecracker-inputs
|
||||||
path: /var/cache/bot-bottle-fc/dropbear
|
path: /var/cache/bot-bottle-fc/dropbear
|
||||||
|
|
||||||
|
# Integration tests against the macOS Apple Container backend. Runs on a
|
||||||
|
# self-hosted macOS runner (label `macos`) registered in HOST mode — Apple
|
||||||
|
# Container needs the host `container` CLI + virtualization framework and
|
||||||
|
# cannot run inside a Linux container, so this cannot reuse the KVM runner.
|
||||||
|
#
|
||||||
|
# Advisory only: workflow_dispatch (manual) exclusively — never push or
|
||||||
|
# pull_request. A single non-redundant laptop that sleeps/roams must not run
|
||||||
|
# unattended on every push to main, let alone block a PR merge, so this job is
|
||||||
|
# deliberately NOT in the `coverage` job's `needs` and its coverage never
|
||||||
|
# feeds the diff-coverage gate. Dispatch-only also means no fork PR (or any
|
||||||
|
# push) ever executes on the host-mode runner.
|
||||||
|
#
|
||||||
|
# The infra container is a singleton (`bot-bottle-mac-infra`); the
|
||||||
|
# `concurrency` group serializes runs so two never collide on it (#425), and
|
||||||
|
# the always-run teardown removes it so a crashed run can't wedge the next.
|
||||||
|
#
|
||||||
|
# Runner prerequisites (provision once; see README "macOS Apple Container"):
|
||||||
|
# the `container` CLI on PATH with `container system status` running, and a
|
||||||
|
# Python >=3.11 with `coverage` importable on the launchd service PATH.
|
||||||
|
integration-macos:
|
||||||
|
runs-on: [self-hosted, macos]
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
concurrency:
|
||||||
|
group: integration-macos-infra
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Fail loudly if the backend this job promises isn't actually usable,
|
||||||
|
# rather than letting every test silently `unittest.skip` and the job go
|
||||||
|
# green on zero coverage. `backend status` exits non-zero (and prints the
|
||||||
|
# per-check summary) when the `container` CLI or its system service is
|
||||||
|
# missing — the same readiness check the skip guards gate on.
|
||||||
|
- name: Preflight — Apple Container backend is ready
|
||||||
|
run: |
|
||||||
|
command -v container >/dev/null || {
|
||||||
|
echo "container CLI not on PATH — provision the runner (README: macOS Apple Container)"; exit 1; }
|
||||||
|
container system status || {
|
||||||
|
echo "container system service not running — run 'container system start'"; exit 1; }
|
||||||
|
python3 cli.py backend status --backend=macos-container
|
||||||
|
|
||||||
|
# `coverage` comes from the runner's provisioned Python (no pip install
|
||||||
|
# into the host interpreter). Advisory job: report coverage in-line for
|
||||||
|
# visibility but don't upload — it never feeds the combined gate.
|
||||||
|
- name: Run integration tests (macos-container) with coverage
|
||||||
|
env:
|
||||||
|
BOT_BOTTLE_BACKEND: macos-container
|
||||||
|
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||||
|
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||||
|
|
||||||
|
- name: Report macos coverage
|
||||||
|
env:
|
||||||
|
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||||
|
run: python3 -m coverage report -m
|
||||||
|
|
||||||
|
# On failure, capture the infra containers' state and logs BEFORE the
|
||||||
|
# teardown below removes them — otherwise a control-plane crash is
|
||||||
|
# undiagnosable from CI, since `stop()` deletes the orchestrator (and its
|
||||||
|
# logs) on every run. Best-effort: never let the diagnostics themselves
|
||||||
|
# fail the job, and keep going if a container is already gone.
|
||||||
|
- name: Dump infra diagnostics (on failure)
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
echo "=== containers ==="
|
||||||
|
container ls -a | grep bot-bottle-mac || echo "(no bot-bottle-mac containers)"
|
||||||
|
echo "=== networks ==="
|
||||||
|
container network ls | grep bot-bottle-mac || echo "(no bot-bottle-mac networks)"
|
||||||
|
for c in bot-bottle-mac-orchestrator bot-bottle-mac-infra; do
|
||||||
|
echo "=== inspect $c ==="
|
||||||
|
container inspect "$c" || echo "($c not found)"
|
||||||
|
echo "=== logs $c ==="
|
||||||
|
container logs "$c" || echo "($c logs unavailable)"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
# Remove the singleton infra container so a crashed or cancelled run
|
||||||
|
# cannot leave `bot-bottle-mac-infra` wedged for the next job.
|
||||||
|
- name: Teardown infra singleton
|
||||||
|
if: always()
|
||||||
|
run: python3 -c 'from bot_bottle.backend.macos_container.infra import MacosInfraService; MacosInfraService().stop()'
|
||||||
|
|
||||||
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
||||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. git-gate runs over the gateway's consolidated `git-http` daemon (the legacy per-bottle `git://` daemon is not used on this backend); keys are provisioned dynamically at launch and revoked on teardown.
|
||||||
|
|
||||||
On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
||||||
|
|
||||||
@@ -75,6 +75,8 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
|
|||||||
|
|
||||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||||
|
|
||||||
|
> **CI (macOS Apple Container):** the `integration-macos` job (`.gitea/workflows/test.yml`) runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container` on a self-hosted macOS runner labelled `macos`, because Apple Container needs the host virtualization framework and cannot run in a Linux container (so it can't reuse the `kvm` runner). Provision an Apple Silicon host with the `container` CLI on `PATH` and `container system status` running, then register the runner in **host mode** (not docker mode) with the `macos` label — `brew install gitea-runner` (the `act_runner` rename). Give it a Python ≥ 3.11 with `coverage` importable on the launchd service's `PATH` (a launchd service doesn't inherit your shell profile, so pin `node` and the Python env explicitly). The job is **advisory** — `workflow_dispatch` (manual) only, never triggered by push or PR — since a single laptop that sleeps/roams must not block merges or churn on every push to main; its coverage doesn't feed the gate. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1.
|
||||||
|
|
||||||
### Containers inside a bottle
|
### Containers inside a bottle
|
||||||
|
|
||||||
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
||||||
|
|||||||
+12
-1
@@ -2,11 +2,22 @@
|
|||||||
|
|
||||||
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
|
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
|
||||||
It runs the unit suite plus one integration job per backend
|
It runs the unit suite plus one integration job per backend
|
||||||
(`integration-docker`, `integration-firecracker`) on:
|
(`integration-docker`, `integration-firecracker`, `integration-macos`) on:
|
||||||
|
|
||||||
- every push to a branch with an open pull request, and
|
- every push to a branch with an open pull request, and
|
||||||
- every push to `main`.
|
- every push to `main`.
|
||||||
|
|
||||||
|
`integration-macos` is the exception: it is **advisory**, running only on
|
||||||
|
`workflow_dispatch` (manual dispatch), never on push or pull requests. It targets the
|
||||||
|
Apple Container backend on a self-hosted macOS runner (label `macos`,
|
||||||
|
registered in host mode — Apple Container can't run in a Linux container, so it
|
||||||
|
can't reuse the `kvm` runner). A single non-redundant laptop must not be able
|
||||||
|
to block a PR merge, so the job stays out of the `coverage` job's `needs` and
|
||||||
|
its coverage never feeds the diff-coverage gate. Because the infra container is
|
||||||
|
a singleton (`bot-bottle-mac-infra`), the job declares a `concurrency` group
|
||||||
|
and tears the container down on exit; keep runner concurrency at 1. See the
|
||||||
|
README "macOS Apple Container" CI note for runner provisioning.
|
||||||
|
|
||||||
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
|
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
|
||||||
runs a **preflight** (`./cli.py backend status --backend=<name>`) that
|
runs a **preflight** (`./cli.py backend status --backend=<name>`) that
|
||||||
prints a clear per-check readiness summary and fails the job when the
|
prints a clear per-check readiness summary and fails the job when the
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# PRD prd-new: macOS (Apple Container) CI runner
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** Claude
|
||||||
|
- **Created:** 2026-07-25
|
||||||
|
- **Issue:** #426
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CI has no runner for the `macos-container` (Apple Container) backend.
|
||||||
|
`.gitea/workflows/test.yml` exercises Docker (`ubuntu-latest`) and
|
||||||
|
Firecracker (self-hosted `kvm`) but never the macOS backend. This PRD adds a
|
||||||
|
self-hosted macOS runner (label `macos`) and an advisory `integration-macos`
|
||||||
|
job that runs the integration suite against `BOT_BOTTLE_BACKEND=macos-container`,
|
||||||
|
so the backend that is the default on macOS stops shipping unexercised.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The gap is not theoretical. `5ad3449` moved `bot_bottle` from flat files under
|
||||||
|
`/app` into a pip-installed package but left init scripts spawning the
|
||||||
|
supervisor as `python3 /app/gateway_init.py`, which no longer exists. Both the
|
||||||
|
Firecracker and macOS backends carried the identical bug:
|
||||||
|
|
||||||
|
- **firecracker** — caught and fixed in `127ba49` because the KVM runner
|
||||||
|
(added in `c193b04`, PR #349) runs that backend's integration suite.
|
||||||
|
- **macos-container** — survived on `main` and only surfaced when a human ran
|
||||||
|
`bot-bottle start` by hand.
|
||||||
|
|
||||||
|
The failure mode is expensive to debug: the supervisor never starts, so
|
||||||
|
mitmdump never generates its CA, and launch dies downstream with
|
||||||
|
`GatewayError: gateway CA not available`, which points at TLS rather than at
|
||||||
|
the supervisor. Unit tests did not help — `test_macos_infra` asserted the
|
||||||
|
substring `"gateway_init.py"`, which the *broken* path satisfies. (That
|
||||||
|
specific assertion has since been tightened to the module form
|
||||||
|
`bot_bottle.gateway_init`, matching its Firecracker twin, so the exact
|
||||||
|
regression is now covered on `ubuntu-latest`. What remains missing is the
|
||||||
|
end-to-end runner that would catch the *next* macOS-only launch regression.)
|
||||||
|
|
||||||
|
PR #470 (#414) already made the integration suite backend-agnostic:
|
||||||
|
`skip_unless_selected_backend_available()` gates on the *selected* backend's
|
||||||
|
own `is_backend_ready()` rather than `docker_available()`, and each
|
||||||
|
integration job runs `./cli.py backend status --backend=<name>` as a preflight
|
||||||
|
that fails loudly when the backend is missing. That is the machinery this job
|
||||||
|
plugs into; this PRD supplies the runner and the job.
|
||||||
|
|
||||||
|
## Goals / Success criteria
|
||||||
|
|
||||||
|
- A macOS runner is registered and picks up jobs by the `macos` label.
|
||||||
|
- An `integration-macos` job runs the integration suite against
|
||||||
|
`BOT_BOTTLE_BACKEND=macos-container`.
|
||||||
|
- The job **fails, not skips**, when the backend is unavailable on the runner
|
||||||
|
(via the `backend status` preflight).
|
||||||
|
- Reverting the `macos_container/infra.py` supervisor fix makes the job fail:
|
||||||
|
the broken supervisor path throws `GatewayError` at bottle launch, which is
|
||||||
|
`TestSandboxEscape.setUpClass`, failing the whole class before any individual
|
||||||
|
attack runs.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Making `integration-macos` a **required** PR check. It runs on
|
||||||
|
`workflow_dispatch` (manual dispatch) only — never on push or PRs. A single
|
||||||
|
non-redundant laptop that sleeps and roams must never be able to block a PR
|
||||||
|
merge or churn unattended on every push to main, and it is deliberately kept
|
||||||
|
out of the `coverage` job's `needs` so the diff-coverage gate never depends on
|
||||||
|
it.
|
||||||
|
- Multi-machine or hosted macOS runners. Apple Container needs the host
|
||||||
|
virtualization framework, so the runner must be a physical/VM macOS host on
|
||||||
|
Apple Silicon — it cannot reuse the KVM runner or run in a Linux container.
|
||||||
|
- Coverage aggregation from the macOS job into the combined gate (would couple
|
||||||
|
the gate to the laptop).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Runner (operational, provisioned once)
|
||||||
|
|
||||||
|
- Apple Silicon macOS host with Apple's `container` CLI installed and
|
||||||
|
`container system status` reporting `running`.
|
||||||
|
- Install the runner: `brew install gitea-runner` (the `act_runner` rename),
|
||||||
|
registered in **host mode** with label `macos` — not docker mode, because
|
||||||
|
Apple Container needs the host `container` CLI and virtualization framework,
|
||||||
|
not a nested container.
|
||||||
|
- A Python ≥ 3.11 with `coverage` importable on the runner's `PATH`. Because a
|
||||||
|
launchd service does not inherit an interactive shell's `PATH`, pin `node`
|
||||||
|
(for the JS `actions/*`) and the Python env explicitly in the service
|
||||||
|
environment rather than relying on `nvm`/shell profile.
|
||||||
|
- Concurrency 1. The infra container is a singleton (`bot-bottle-mac-infra`),
|
||||||
|
so two simultaneous runs on one host collide (#425). The job also declares a
|
||||||
|
`concurrency` group as belt-and-suspenders and tears the singleton down after
|
||||||
|
each run.
|
||||||
|
|
||||||
|
### `integration-macos` job
|
||||||
|
|
||||||
|
Modeled on `integration-firecracker`:
|
||||||
|
|
||||||
|
- `runs-on: [self-hosted, macos]`.
|
||||||
|
- `if:` `workflow_dispatch` only (advisory, manual dispatch; never push or PRs,
|
||||||
|
so no fork-PR exposure, no merge-blocking, and no unattended runs on push).
|
||||||
|
- `concurrency: { group: integration-macos-infra, cancel-in-progress: false }`
|
||||||
|
to serialize runs against the singleton.
|
||||||
|
- **Preflight** — `command -v container`, `container system status`, then
|
||||||
|
`./cli.py backend status --backend=macos-container`; any failure exits
|
||||||
|
non-zero so a misprovisioned runner fails loudly instead of silently
|
||||||
|
skipping.
|
||||||
|
- Run the integration suite under coverage with
|
||||||
|
`BOT_BOTTLE_BACKEND=macos-container` and print a `coverage report -m` for
|
||||||
|
visibility (no upload, not in the gate).
|
||||||
|
- **Teardown** (`if: always()`) — `MacosInfraService().stop()` removes the
|
||||||
|
singleton so a crashed run cannot wedge the next one.
|
||||||
|
|
||||||
|
### The `test_sandbox_escape` CI guard (the trap #470 left)
|
||||||
|
|
||||||
|
`TestSandboxEscape` is the only backend-agnostic integration test that boots a
|
||||||
|
real bottle, so it is the one that would catch a macOS launch regression. It
|
||||||
|
still carries a second guard that skips under `GITEA_ACTIONS` for every backend
|
||||||
|
except `firecracker`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true"
|
||||||
|
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
||||||
|
...,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The skip exists because the *containerized* `act_runner` (docker on
|
||||||
|
`ubuntu-latest`) can't see a host bind mount and hides sibling-gateway network
|
||||||
|
topology. Those constraints do not apply to a **host-mode** runner — neither
|
||||||
|
the KVM host runner nor a macOS host runner is containerized. This PRD relaxes
|
||||||
|
the guard to allow both host-mode backends (`firecracker`, `macos-container`)
|
||||||
|
through while still skipping on the containerized Docker job. Without this
|
||||||
|
change the macOS job would run green while skipping the exact test that proves
|
||||||
|
the backend launches — the very false-green this issue is about.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- None known that block the job. git-gate is fully implemented on the macOS
|
||||||
|
backend (the gateway's consolidated `git-http` daemon plus dynamic key
|
||||||
|
provisioning/revocation), so `TestSandboxEscape` attack 5 — secret exfil
|
||||||
|
pushed through git-gate, rejected by the gitleaks hook before the upstream
|
||||||
|
push — runs the same as on the other backends. Any genuinely
|
||||||
|
macOS-specific test adjustment would surface at first runner bring-up, but
|
||||||
|
none is anticipated from the current backend implementation.
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
# PRD prd-new: Trusted agent forge identity and guidance
|
|
||||||
|
|
||||||
- **Status:** Draft
|
|
||||||
- **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.
|
|
||||||
@@ -67,14 +67,25 @@ _DUMMY_HOST_KEY = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Backends whose CI runner is HOST-mode (self-hosted), so the test process
|
||||||
|
# and the backend share a host. The containerized act_runner (docker on
|
||||||
|
# ubuntu-latest) is the one that can't see the host bind mount egress_tls_init
|
||||||
|
# uses and hides sibling-gateway network topology; host-mode runners
|
||||||
|
# (firecracker/KVM, macos-container) don't have those constraints, so the test
|
||||||
|
# runs there. Keep this in sync with the `runs-on` labels in
|
||||||
|
# .gitea/workflows/test.yml.
|
||||||
|
_HOST_MODE_CI_BACKENDS = frozenset({"firecracker", "macos-container"})
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_selected_backend_available()
|
@skip_unless_selected_backend_available()
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true"
|
os.environ.get("GITEA_ACTIONS") == "true"
|
||||||
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
and os.environ.get("BOT_BOTTLE_BACKEND") not in _HOST_MODE_CI_BACKENDS,
|
||||||
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
|
"skipped under the containerized act_runner (docker on ubuntu-latest): "
|
||||||
"egress_tls_init uses a host bind mount the runner container can't "
|
"egress_tls_init uses a host bind mount the runner container can't "
|
||||||
"see, and the network topology hides sibling-gateway visibility — "
|
"see, and the network topology hides sibling-gateway visibility — "
|
||||||
"these constraints don't apply on the self-hosted KVM runner",
|
"these constraints don't apply on the self-hosted host-mode runners "
|
||||||
|
"(firecracker/KVM, macos-container)",
|
||||||
)
|
)
|
||||||
class TestSandboxEscape(unittest.TestCase):
|
class TestSandboxEscape(unittest.TestCase):
|
||||||
"""End-to-end attacks against a real bottle. The bottle stays
|
"""End-to-end attacks against a real bottle. The bottle stays
|
||||||
|
|||||||
Reference in New Issue
Block a user