Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cdedbb7ca |
@@ -217,7 +217,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -227,13 +227,9 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
# Defense in depth: skill names are validated kebab-case at
|
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||||
# manifest load, but quote the path so a future unvalidated
|
|
||||||
# field can't inject shell metacharacters here either.
|
|
||||||
dst_q = shlex.quote(dst)
|
|
||||||
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -193,13 +193,9 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
# Defense in depth: skill names are validated kebab-case at
|
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||||
# manifest load, but quote the path so a future unvalidated
|
|
||||||
# field can't inject shell metacharacters here either.
|
|
||||||
dst_q = shlex.quote(dst)
|
|
||||||
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class PiAgentProvider(AgentProvider):
|
|||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -248,13 +248,9 @@ class PiAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
# Defense in depth: skill names are validated kebab-case at
|
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||||
# manifest load, but quote the path so a future unvalidated
|
|
||||||
# field can't inject shell metacharacters here either.
|
|
||||||
dst_q = shlex.quote(dst)
|
|
||||||
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
prompt_path = _prompt_path(plan.guest_home)
|
prompt_path = _prompt_path(plan.guest_home)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import cast
|
|||||||
from .agent_provider import PROVIDER_TEMPLATES
|
from .agent_provider import PROVIDER_TEMPLATES
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
from .manifest_git import ManifestGitUser
|
from .manifest_git import ManifestGitUser
|
||||||
from .manifest_schema import AGENT_MODEL_KEYS, is_valid_entity_name
|
from .manifest_schema import AGENT_MODEL_KEYS
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -161,16 +161,6 @@ class ManifestAgent:
|
|||||||
f"agent '{name}' skills[{i}] must be a string "
|
f"agent '{name}' skills[{i}] must be a string "
|
||||||
f"(was {type(skill).__name__})"
|
f"(was {type(skill).__name__})"
|
||||||
)
|
)
|
||||||
# Skill names become host/guest path segments and are
|
|
||||||
# interpolated into provisioning shell commands, so they
|
|
||||||
# must fit the same kebab-case convention as bottle/agent
|
|
||||||
# filenames — rejecting anything that could break out of a
|
|
||||||
# path segment or inject shell metacharacters.
|
|
||||||
if not is_valid_entity_name(skill):
|
|
||||||
raise ManifestError(
|
|
||||||
f"agent '{name}' skills[{i}] {skill!r} is not a valid "
|
|
||||||
f"skill name; must match [a-z][a-z0-9-]*"
|
|
||||||
)
|
|
||||||
collected.append(skill)
|
collected.append(skill)
|
||||||
skills = tuple(collected)
|
skills = tuple(collected)
|
||||||
|
|
||||||
|
|||||||
@@ -33,20 +33,13 @@ AGENT_KEYS = (
|
|||||||
AGENT_MODEL_KEYS = AGENT_KEYS | frozenset({"prompt"})
|
AGENT_MODEL_KEYS = AGENT_KEYS | frozenset({"prompt"})
|
||||||
|
|
||||||
|
|
||||||
def is_valid_entity_name(name: str) -> bool:
|
|
||||||
"""True if `name` fits the kebab-case `[a-z][a-z0-9-]*` convention
|
|
||||||
shared by bottle/agent filenames and skill names. Names that satisfy
|
|
||||||
this are also safe to interpolate into a host/guest path segment."""
|
|
||||||
return bool(_FILENAME_RX.match(name))
|
|
||||||
|
|
||||||
|
|
||||||
def entity_name_from_path(path: Path) -> str | None:
|
def entity_name_from_path(path: Path) -> str | None:
|
||||||
"""Return the entity name implied by the filename, or None if the
|
"""Return the entity name implied by the filename, or None if the
|
||||||
filename does not fit the [a-z][a-z0-9-]* convention."""
|
filename does not fit the [a-z][a-z0-9-]* convention."""
|
||||||
if path.suffix != ".md":
|
if path.suffix != ".md":
|
||||||
return None
|
return None
|
||||||
stem = path.stem
|
stem = path.stem
|
||||||
if not is_valid_entity_name(stem):
|
if not _FILENAME_RX.match(stem):
|
||||||
return None
|
return None
|
||||||
return stem
|
return stem
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# PRD prd-new: Egress control plane — metering, budgets, and forced cutoff
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-06-25
|
||||||
|
- **Issue:** #251
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an **out-of-band egress enforcement & observability plane**: meter every
|
||||||
|
agent's token usage at the egress proxy, decrement budgets without the agent's
|
||||||
|
cooperation, and forcibly cut a bottle's egress when a budget is exhausted —
|
||||||
|
either automatically or on command from a host-level dashboard. The trigger
|
||||||
|
(usage threshold) and the action (route-drop / freeze / kill) both live in the
|
||||||
|
egress plane and run with no agent in the loop. This is distinct from the
|
||||||
|
supervise sidecar (PRD 0013), which is agent-initiated and therefore cannot
|
||||||
|
enforce a cost cutoff on a runaway agent. State (usage ledger, budgets, audit)
|
||||||
|
moves into a host-level SQLite database behind a thin repository API, the first
|
||||||
|
SQL store in an otherwise flat-file repo.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
bot-bottle can't currently do two things the cost-overrun case demands:
|
||||||
|
|
||||||
|
1. **Forced egress shutdown on limit.** When an agent crosses a token
|
||||||
|
threshold, kill its egress automatically — no human in the loop.
|
||||||
|
2. **Remote (host-level) management.** Drive agents from a single surface:
|
||||||
|
see usage, cut egress, stop bottles, to prevent cost overruns.
|
||||||
|
|
||||||
|
The existing supervise sidecar (PRD 0013) is **entirely agent-initiated**: every
|
||||||
|
action begins with the agent voluntarily calling an MCP tool and an operator
|
||||||
|
approving it. A runaway or expensive agent — exactly the cost-overrun case —
|
||||||
|
will never call `egress-block` on itself. Supervision is therefore a
|
||||||
|
**collaborative recovery** mechanism, not an **enforcement** mechanism; making
|
||||||
|
it mandatory (#249) would not deliver forced cost-cutoff.
|
||||||
|
|
||||||
|
The requirement forces a distinction the current design blurs:
|
||||||
|
|
||||||
|
- **Plane A — enforcement / observability (this PRD).** System → infrastructure.
|
||||||
|
Meter usage, cut egress on threshold or command, account for cost.
|
||||||
|
Out-of-band; independent of the agent. **Unconditional** — an enforcement
|
||||||
|
plane you can opt out of isn't enforcement.
|
||||||
|
- **Plane B — agent-facing recovery (the existing supervise sidecar).**
|
||||||
|
Agent → operator, approval-gated. Useful interactively; meaningless for a
|
||||||
|
headless agent with no operator watching its queue. Remains optional.
|
||||||
|
|
||||||
|
This PRD builds Plane A. It reframes the "always-on control" invariant of #249
|
||||||
|
as "the egress control plane is always present" — a more defensible property
|
||||||
|
than "every agent runs the agent-facing supervisor." Unsupervised
|
||||||
|
(headless/CI/ephemeral) agents stay first-class: still subject to the mandatory
|
||||||
|
meter + kill switch, they simply lack the agent-facing proposal tools they
|
||||||
|
couldn't use anyway.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- The egress proxy meters every request to a metered API host (e.g.
|
||||||
|
`api.anthropic.com`) and records authoritative token usage per bottle and per
|
||||||
|
agent provider, with no agent cooperation.
|
||||||
|
- A budget can be set at four scopes with deterministic precedence
|
||||||
|
(**agent → bottle → parent bottle → global host budget**); the
|
||||||
|
most-specific applicable budget governs.
|
||||||
|
- When usage crosses a budget, the bottle's configured **cutoff policy**
|
||||||
|
(`cutoff` | `freeze` | `kill`) fires automatically, executed host-side on the
|
||||||
|
egress plane — never via the supervise queue.
|
||||||
|
- An operator can, from a single **host-level TUI dashboard**, see live per-bottle
|
||||||
|
usage against budget and command a cutoff/stop on demand.
|
||||||
|
- Host budgets, default cutoff policy, and per-provider limits are declared in a
|
||||||
|
new host-level `~/.bot-bottle/settings.yml`, parseable by `yaml_subset.py`.
|
||||||
|
- All usage, budget state, and enforcement actions persist in a host-level
|
||||||
|
SQLite DB behind a thin repository API, so the store can later be swapped for
|
||||||
|
a cross-host cloud service.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **Remote control / cross-host control plane.** Web + mobile remote control,
|
||||||
|
cross-host budgets, and the authn/transport they require are explicitly
|
||||||
|
deferred. v1 is a **host-only TUI** with no remote surface.
|
||||||
|
- **Dollar-denominated budgets.** Budgets are token counts keyed by agent
|
||||||
|
provider, not currency. Price tables are out of scope.
|
||||||
|
- **Migrating existing flat-file state into SQLite.** Resume `metadata.json`,
|
||||||
|
transcripts, Dockerfile overrides, the supervise queue, and audit logs stay on
|
||||||
|
the filesystem. Only the *new* metering/budget/enforcement ledger is SQL.
|
||||||
|
- **Making the supervise sidecar (Plane B) mandatory.** Out of scope here; this
|
||||||
|
PRD is the answer to "what should be unconditional" (Plane A), leaving #249's
|
||||||
|
Plane-B question open.
|
||||||
|
- **Per-request hard pre-send blocking as the primary mechanism.** The gate is
|
||||||
|
budget-crossing detected at/after metering; a pre-flight estimator (below) is a
|
||||||
|
refinement, not the core enforcement path.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Two measurements: gate vs. account
|
||||||
|
|
||||||
|
There are two distinct needs, and they want different signals:
|
||||||
|
|
||||||
|
- **Account (authoritative).** Decrement the real budget from the API
|
||||||
|
**response**, which already carries authoritative usage (Anthropic
|
||||||
|
`input_tokens` / `output_tokens`, OpenAI `usage`). The egress addon already
|
||||||
|
has a `response(flow)` hook (`bot_bottle/egress_addon.py:460`), so the real
|
||||||
|
number is available with no extra network call. **Caveat:** agent traffic is
|
||||||
|
mostly streaming SSE, so the response path must tail the stream for the final
|
||||||
|
usage event rather than parse a single JSON body — scoped explicitly as work.
|
||||||
|
- **Gate (estimate).** To block *before* sending, only the request is available,
|
||||||
|
so an estimator / provider `count_tokens` endpoint is the only option.
|
||||||
|
|
||||||
|
Calling `count_tokens` for accounting would be both less accurate *and* an extra
|
||||||
|
metered egress call per request, so accounting uses response `usage` and the
|
||||||
|
estimator is reserved for the optional pre-flight gate.
|
||||||
|
|
||||||
|
### `count_tokens` on agent providers
|
||||||
|
|
||||||
|
Add an abstract `count_tokens(request) -> int` to the `AgentProvider`
|
||||||
|
abstraction (`bot_bottle/agent_provider.py`):
|
||||||
|
|
||||||
|
- **Default** is a good-enough stdlib estimator. Prefer stdlib only; a small
|
||||||
|
pip dependency *for the sidecar* is acceptable for the fallback if stdlib
|
||||||
|
proves too inaccurate (this does not relax the package's stdlib-first stance —
|
||||||
|
it would be a sidecar-only dep, like the bundle already carries).
|
||||||
|
- **Built-in `claude`** uses Anthropic's token-counting endpoint;
|
||||||
|
**built-in `codex`** uses OpenAI's. These are exact for the gate but cost a
|
||||||
|
metered call, so they are gate-only; accounting still comes from the response.
|
||||||
|
|
||||||
|
### Budgets and precedence
|
||||||
|
|
||||||
|
Budgets are token counts keyed by **agent provider name** (the same names
|
||||||
|
bottles already use). Four scopes, most-specific wins:
|
||||||
|
|
||||||
|
```
|
||||||
|
agent → bottle → parent bottle → global (host)
|
||||||
|
```
|
||||||
|
|
||||||
|
The global host budget is the highest-priority feature to ship (the cross-host
|
||||||
|
control plane will eventually consume it); per-agent and per-bottle budgets
|
||||||
|
override it for finer control. A budget can also be supplied **at bottle
|
||||||
|
launch** (`--budget` or equivalent), overriding the settings.yml defaults for
|
||||||
|
that run. Enforcement evaluates the effective budget as the
|
||||||
|
nearest-defined scope at decrement time.
|
||||||
|
|
||||||
|
### `~/.bot-bottle/settings.yml`
|
||||||
|
|
||||||
|
New **host-level** settings file (the `~/.bot-bottle/` root, *not* the per-repo
|
||||||
|
`.bot-bottle/` — host budgets must not be committed per-repo). Parsed by
|
||||||
|
`yaml_subset.py`, so it must stay within that bounded subset (flat mappings,
|
||||||
|
scalars; no anchors, no multi-line block scalars). Shape:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
budget:
|
||||||
|
claude: 5000000 # token budget keyed by agent provider
|
||||||
|
codex: 2000000
|
||||||
|
shutdown: cutoff # default cutoff policy: cutoff | freeze | kill
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forced cutoff and cutoff policy
|
||||||
|
|
||||||
|
On budget exhaustion (or an operator command), the configured per-bottle cutoff
|
||||||
|
policy fires. The three policies map onto primitives that already exist:
|
||||||
|
|
||||||
|
- **`cutoff`** (default) — drop the bottle's `routes.yaml` to empty and reload
|
||||||
|
(or isolate the bottle from the egress network); the agent/bottle keeps
|
||||||
|
running but can no longer reach metered hosts. This is the route-drop already
|
||||||
|
available on the egress plane (`bot_bottle/backend/egress_apply.py`).
|
||||||
|
- **`freeze`** — commit/snapshot state, then kill the agent/bottle; resumable
|
||||||
|
later via `bot_bottle/backend/freeze.py`.
|
||||||
|
- **`kill`** — tear the bottle down without saving state (backend teardown).
|
||||||
|
|
||||||
|
The trigger lives in the metering path and the action in the egress/backend
|
||||||
|
plane; **neither touches the supervise proposal queue** (design constraint from
|
||||||
|
#251).
|
||||||
|
|
||||||
|
### Host-level SQLite store
|
||||||
|
|
||||||
|
**Decision: introduce SQLite now, narrowly.**
|
||||||
|
|
||||||
|
- **The dependency objection doesn't apply.** `sqlite3` is in the Python stdlib,
|
||||||
|
so it does not break the AGENTS.md stdlib-first / no-runtime-pip stance — same
|
||||||
|
category as the hand-rolled `yaml_subset.py`, except the stdlib already ships
|
||||||
|
the whole engine.
|
||||||
|
- **It fits the problem.** A *global* token budget decremented concurrently by N
|
||||||
|
egress sidecars (today `~/.bot-bottle/` already has `state/`, `audit/`,
|
||||||
|
`queue/` written by parallel bottles) is a read-modify-write race. Over JSON
|
||||||
|
that means hand-rolled file locking; SQLite gives atomic transactions + WAL for
|
||||||
|
free. The per-agent/per-bottle precedence rollup plus "sum across all bottles"
|
||||||
|
is a `GROUP BY`, not an N-directory rescan.
|
||||||
|
- **It rehearses the cloud swap.** "Wrap operations in an API so we can swap to a
|
||||||
|
cloud service" maps directly onto a thin repository/DAO over SQLite → Postgres
|
||||||
|
later. A JSON-file store is a worse rehearsal than SQL.
|
||||||
|
|
||||||
|
**Costs (real but bounded):** a new paradigm in a flat-file repo needs a
|
||||||
|
`schema_version` table + idempotent startup migrations; SQLite serializes
|
||||||
|
writers, so WAL mode + `busy_timeout` are required (a non-issue at a handful of
|
||||||
|
bottles); test fixtures need temp DBs.
|
||||||
|
|
||||||
|
**Scope of the store:** one DB at `~/.bot-bottle/bot-bottle.db` behind a thin
|
||||||
|
repository API. Only the **new** metering/budget/enforcement-audit ledger lives
|
||||||
|
there. Existing per-bottle blobs (resume `metadata.json`, transcripts,
|
||||||
|
Dockerfile overrides, supervise queue) stay on the filesystem — migrating them
|
||||||
|
now is churn for no benefit and they lack the concurrency/aggregation problem.
|
||||||
|
|
||||||
|
### Host-level controller + dashboard
|
||||||
|
|
||||||
|
A single **host-level controller** owns the meter, budget evaluation, and the
|
||||||
|
cutoff actions across all bottles (cf. `bot_bottle/cli/supervise.py`'s
|
||||||
|
cross-bottle view), rather than a per-bottle daemon. v1 ships one host-level
|
||||||
|
**TUI dashboard** that reads live usage-vs-budget from the SQLite store and
|
||||||
|
offers on-demand cutoff/stop. The existing supervisor UI should eventually fold
|
||||||
|
into this same dashboard; this PRD lays the host-level surface it will move to.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Ordered, individually mergeable:
|
||||||
|
|
||||||
|
1. **SQLite repository foundation.** `~/.bot-bottle/bot-bottle.db`, schema +
|
||||||
|
`schema_version` migrations, WAL + `busy_timeout`, thin repository API,
|
||||||
|
temp-DB test fixtures. No behavior wired yet.
|
||||||
|
2. **Metering at the egress proxy.** Parse authoritative response `usage`
|
||||||
|
(including SSE final-usage tailing) in the egress addon `response` hook;
|
||||||
|
write per-bottle / per-provider usage rows to the ledger.
|
||||||
|
3. **`settings.yml` + budget model.** Host-level `~/.bot-bottle/settings.yml`
|
||||||
|
parsed by `yaml_subset.py`; budget precedence (agent → bottle → parent →
|
||||||
|
global) and the `--budget` launch flag.
|
||||||
|
4. **Forced cutoff + cutoff policy.** Wire the threshold trigger to the
|
||||||
|
`cutoff` / `freeze` / `kill` primitives on the egress/backend plane; record
|
||||||
|
enforcement actions to the audit ledger.
|
||||||
|
5. **Host-level TUI dashboard.** Live usage-vs-budget view + on-demand
|
||||||
|
cutoff/stop, reading the store.
|
||||||
|
6. **`count_tokens` pre-flight gate (optional refinement).** Abstract method +
|
||||||
|
stdlib estimator default; Anthropic/OpenAI endpoints for built-in
|
||||||
|
claude/codex; optional pre-send block.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **SSE usage tailing robustness.** Buffering streamed responses to extract the
|
||||||
|
final usage event without breaking the agent's own stream consumption — how
|
||||||
|
much of the body must the addon hold, and what's the failure mode if the
|
||||||
|
stream is interrupted mid-flight?
|
||||||
|
- **Crossing mid-request.** A single response can push usage past budget only
|
||||||
|
*after* it's already been delivered. Is post-hoc cutoff (next request blocked)
|
||||||
|
sufficient, or is a pre-flight estimator gate (chunk 6) required for v1?
|
||||||
|
- **Provider name ↔ metered host mapping.** How does the proxy attribute a
|
||||||
|
flow to an agent-provider budget key — by destination host, by bottle
|
||||||
|
identity, or both?
|
||||||
|
- **Parent-bottle budget semantics.** For `bottle extends` (PRD 0025 / 0065)
|
||||||
|
chains, does "parent bottle" mean the manifest parent, the launching bottle,
|
||||||
|
or the full ancestry summed?
|
||||||
|
- **Dashboard ↔ controller transport (even host-only).** In-process, a local
|
||||||
|
socket, or polling the SQLite store directly? Picks the seam the future remote
|
||||||
|
control plane will extend.
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
# PRD prd-new: Forge native integration
|
|
||||||
|
|
||||||
- **Status:** Draft
|
|
||||||
- **Author:** claude
|
|
||||||
- **Created:** 2026-06-29
|
|
||||||
- **Issue:** #317
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
|
|
||||||
drive bot-bottle sessions end-to-end with no operator in the loop for the happy
|
|
||||||
path. An issue assigned to a member of the configured agent org and labelled
|
|
||||||
with an agent name triggers a headless bottle launch; the bottle processes the
|
|
||||||
issue, opens a PR, and posts a done-comment via the Gitea API (through
|
|
||||||
cred-proxy) before exiting. The orchestrator detects the done-comment, freezes
|
|
||||||
the bottle, and attaches a provenance footer. Subsequent PR comments rehydrate
|
|
||||||
the frozen bottle. The bottle is destroyed when the PR closes.
|
|
||||||
|
|
||||||
The separation of concerns across the two layers: bot-bottle owns the headless
|
|
||||||
launch primitives, forge state, Gitea client, and provenance builder.
|
|
||||||
`bot-bottle-orchestrator` (separate binary) owns the webhook listener, bottle
|
|
||||||
lifecycle loop, and monitoring dashboard; it calls into bot-bottle via
|
|
||||||
`./cli.py orchestrate`, a thin wrapper command. This PRD covers bot-bottle's
|
|
||||||
side of that contract.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Today an operator must open the TUI, select an agent and bottle, confirm the
|
|
||||||
preflight, and type prompts interactively. This blocks "issue → PR" automation
|
|
||||||
and produces no durable audit record of what the agent did. The security model
|
|
||||||
already provides the right isolation and egress controls; the missing pieces are
|
|
||||||
the headless launch primitive that `bot-bottle-orchestrator` can call, the
|
|
||||||
in-bottle Gitea API access the agent uses to signal completion, and the
|
|
||||||
provenance trail that makes the audit story legible to reviewers on every PR.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
1. `./cli.py orchestrate start` and `./cli.py orchestrate resume` are the
|
|
||||||
non-interactive counterparts to `start` and `resume`. They accept agent,
|
|
||||||
bottle, and prompt via flags rather than TUI pickers, and exit when the
|
|
||||||
agent process exits.
|
|
||||||
2. An issue assigned to a member of the configured org (`FORGE_ORG`, default
|
|
||||||
`bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
|
|
||||||
convention. Org membership is verified via the Gitea API at event time.
|
|
||||||
3. Forge-targeted bottles receive a set of env vars at launch
|
|
||||||
(`FORGE_GITEA_API`, `FORGE_OWNER`, `FORGE_REPO`, `FORGE_ISSUE_NUMBER`) so
|
|
||||||
the agent knows where to post its done-comment without hardcoding forge
|
|
||||||
context in the agent manifest.
|
|
||||||
4. The agent's egress policy for forge runs includes `gitea.<host>` with Bearer
|
|
||||||
auth injected by cred-proxy, enabling direct Gitea API calls from inside the
|
|
||||||
bottle.
|
|
||||||
5. The done-comment the agent posts is the done signal. A watchdog timeout
|
|
||||||
(configurable, default 30 min) causes the orchestrator to post the
|
|
||||||
done-comment on the agent's behalf if the agent exits without posting one.
|
|
||||||
6. Every orchestrator-posted comment ends with a provenance footer: agent name,
|
|
||||||
bottle name(s), slug, start time, duration, exit code, gitleaks result, and
|
|
||||||
egress summary.
|
|
||||||
7. Forge state (issue → slug, status) is persisted to disk and survives
|
|
||||||
orchestrator restarts.
|
|
||||||
8. `./cli.py orchestrate status` lists active forge-managed bottles and their
|
|
||||||
issue/PR URLs.
|
|
||||||
9. Unit tests cover: label parsing, org-membership check path, forge state
|
|
||||||
read/write, provenance footer rendering, headless launch arg construction,
|
|
||||||
forge env var injection, echo-loop guard.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Webhook signature verification (HMAC-SHA256). Added as a follow-up.
|
|
||||||
- The `bot-bottle-orchestrator` binary itself — this PRD covers bot-bottle's
|
|
||||||
side of the interface only. The orchestrator is a separate project.
|
|
||||||
- GitHub or GitLab support.
|
|
||||||
- Multiple simultaneous forge bottles per issue.
|
|
||||||
- Automatic retry on agent error exit.
|
|
||||||
- Bottle destruction on issue close (PR close only; issue close is ambiguous).
|
|
||||||
- Concurrent multi-issue handling (one blocking run per orchestrator process).
|
|
||||||
- A monitoring dashboard (orchestrator-side concern).
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Targeting convention
|
|
||||||
|
|
||||||
An issue is forge-targeted when **both** hold:
|
|
||||||
|
|
||||||
- At least one assignee is a member of the Gitea org named by `FORGE_ORG`
|
|
||||||
(default `bot-bottle`). Checked via `GET /api/v1/orgs/{org}/members/{user}`.
|
|
||||||
- At least one label has the prefix `bot-bottle:`. The suffix names the agent
|
|
||||||
manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
|
|
||||||
|
|
||||||
`FORGE_ORG` is read at orchestrate-command startup. It is not embedded in
|
|
||||||
manifests or state files; the orchestrator stamps its value into log output for
|
|
||||||
auditability.
|
|
||||||
|
|
||||||
An optional label `bot-bottle-bottle:<name>` overrides bottle selection. When
|
|
||||||
absent the agent's default bottle is used.
|
|
||||||
|
|
||||||
### `./cli.py orchestrate` — the thin wrapper
|
|
||||||
|
|
||||||
```
|
|
||||||
./cli.py orchestrate start --agent AGENT [--bottle BOTTLE ...] --prompt PROMPT
|
|
||||||
[--label LABEL] [--backend BACKEND]
|
|
||||||
./cli.py orchestrate resume --slug SLUG --prompt PROMPT [--backend BACKEND]
|
|
||||||
./cli.py orchestrate status
|
|
||||||
```
|
|
||||||
|
|
||||||
`orchestrate start` is `start_headless` exposed as a subcommand. It prepares
|
|
||||||
the bottle non-interactively, launches the agent in print mode, and exits
|
|
||||||
with the agent's exit code. The caller (`bot-bottle-orchestrator`) manages
|
|
||||||
freeze, state, and Gitea comments around it.
|
|
||||||
|
|
||||||
`orchestrate resume` is `resume_headless` exposed as a subcommand.
|
|
||||||
|
|
||||||
`orchestrate status` prints the forge state table.
|
|
||||||
|
|
||||||
### Headless primitives
|
|
||||||
|
|
||||||
**`attach_agent_headless`** — new function in `bot_bottle/cli/start.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def attach_agent_headless(
|
|
||||||
bottle: Bottle,
|
|
||||||
*,
|
|
||||||
prompt: str,
|
|
||||||
resume: bool = False,
|
|
||||||
agent_provider_template: str = "claude",
|
|
||||||
startup_args: tuple[str, ...] = (),
|
|
||||||
) -> int:
|
|
||||||
runtime = runtime_for(agent_provider_template)
|
|
||||||
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
|
|
||||||
agent_args.extend(startup_args)
|
|
||||||
agent_args.append("--no-interactive")
|
|
||||||
if resume:
|
|
||||||
agent_args.extend(runtime.resume_args) # --continue
|
|
||||||
agent_args.extend(["-p", prompt])
|
|
||||||
return bottle.exec_agent(agent_args, tty=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`start_headless`** — new function in `bot_bottle/cli/start.py` that mirrors
|
|
||||||
`_launch_bottle` without any TUI steps:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def start_headless(
|
|
||||||
manifest: ManifestIndex,
|
|
||||||
*,
|
|
||||||
agent_name: str,
|
|
||||||
bottle_names: tuple[str, ...],
|
|
||||||
label: str,
|
|
||||||
prompt: str,
|
|
||||||
forge_env: dict[str, str] | None = None,
|
|
||||||
backend_name: str | None = None,
|
|
||||||
) -> tuple[str, int]:
|
|
||||||
"""Non-interactive bottle launch. Returns (slug, exit_code)."""
|
|
||||||
```
|
|
||||||
|
|
||||||
`forge_env` is merged into the bottle's `guest_env` so the agent receives the
|
|
||||||
forge context as env vars (see below). The caller freezes the bottle after
|
|
||||||
`start_headless` returns.
|
|
||||||
|
|
||||||
**`resume_headless`** — new function in `bot_bottle/cli/resume.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def resume_headless(slug: str, *, prompt: str, backend_name: str | None = None) -> int:
|
|
||||||
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
|
|
||||||
```
|
|
||||||
|
|
||||||
### Forge env vars
|
|
||||||
|
|
||||||
The orchestrator builds this dict and passes it to `start_headless` as
|
|
||||||
`forge_env`:
|
|
||||||
|
|
||||||
| Var | Example | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `FORGE_GITEA_API` | `https://gitea.dideric.is/api/v1` | Base URL for Gitea API calls |
|
|
||||||
| `FORGE_OWNER` | `didericis` | Repo owner |
|
|
||||||
| `FORGE_REPO` | `bot-bottle` | Repo name |
|
|
||||||
| `FORGE_ISSUE_NUMBER` | `317` | Issue that triggered the run |
|
|
||||||
| `FORGE_PR_NUMBER` | `318` | PR to comment on (empty until PR exists) |
|
|
||||||
|
|
||||||
The agent's system prompt (from the manifest) instructs it to post a comment to
|
|
||||||
`$FORGE_GITEA_API/repos/$FORGE_OWNER/$FORGE_REPO/issues/$FORGE_ISSUE_NUMBER/comments`
|
|
||||||
when it finishes a work unit. The instruction is part of the forge-specific
|
|
||||||
agent prompt, not the base agent manifest, so non-forge runs are unaffected.
|
|
||||||
|
|
||||||
### Gitea egress for forge-targeted bottles
|
|
||||||
|
|
||||||
Forge-targeted bottles get an additional egress route injected by the
|
|
||||||
orchestrator at launch time. This is passed as an extra `EgressRoute` in the
|
|
||||||
`BottleSpec` (or via the forge env and bottle manifest) rather than requiring
|
|
||||||
operators to add it to every agent manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
host: gitea.dideric.is
|
|
||||||
auth:
|
|
||||||
scheme: Bearer
|
|
||||||
token_env: GITEA_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
The cred-proxy injects the token; the agent never sees the raw credential.
|
|
||||||
|
|
||||||
### Done signal and watchdog
|
|
||||||
|
|
||||||
The agent posts a Gitea comment when it finishes a work unit. The orchestrator
|
|
||||||
webhook listener receives the `issue_comment` event and:
|
|
||||||
|
|
||||||
1. Verifies the commenter is a member of `FORGE_ORG`.
|
|
||||||
2. Reads the forge state for `(owner, repo, issue_number)`.
|
|
||||||
3. If `status == "running"`, treats the comment as the done signal: freezes the
|
|
||||||
bottle, appends the provenance footer to the same comment thread, sets
|
|
||||||
`status = "frozen"`.
|
|
||||||
|
|
||||||
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state. A
|
|
||||||
background thread wakes every minute. If `now - last_checkin_at > FORGE_WATCHDOG_TIMEOUT`
|
|
||||||
(default 30 min, configurable via env) and `status == "running"`, the
|
|
||||||
orchestrator posts the provenance footer comment on behalf of the agent and
|
|
||||||
freezes the bottle.
|
|
||||||
|
|
||||||
Echo-loop guard: comments from members of `FORGE_ORG` that are not the
|
|
||||||
currently-running slug's agent user are still dispatched as resume triggers, not
|
|
||||||
as done signals. The comment-is-done-signal path checks that
|
|
||||||
`comment.user.login == agent_git_user` (read from forge state).
|
|
||||||
|
|
||||||
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.bot-bottle/forge/
|
|
||||||
<owner>/
|
|
||||||
<repo>/
|
|
||||||
issue-<n>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Schema:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"slug": "implementer-abc12",
|
|
||||||
"pr_number": 42,
|
|
||||||
"agent_name": "implementer",
|
|
||||||
"bottle_names": ["claude"],
|
|
||||||
"backend_name": "docker",
|
|
||||||
"agent_git_user": "didericis-claude",
|
|
||||||
"issue_number": 17,
|
|
||||||
"owner": "didericis",
|
|
||||||
"repo": "bot-bottle",
|
|
||||||
"status": "frozen",
|
|
||||||
"last_checkin_at": "2026-06-29T12:04:12-04:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`status`: `"running"` | `"frozen"` | `"destroyed"`.
|
|
||||||
|
|
||||||
Public API:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def write_forge_state(state: ForgeState) -> None: ...
|
|
||||||
def read_forge_state(owner: str, repo: str, issue_number: int) -> ForgeState | None: ...
|
|
||||||
def delete_forge_state(owner: str, repo: str, issue_number: int) -> None: ...
|
|
||||||
def all_forge_states() -> list[ForgeState]: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Writes use atomic rename (`os.replace`) for crash safety.
|
|
||||||
|
|
||||||
### Provenance — `bot_bottle/contrib/gitea/provenance.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def build_provenance_footer(
|
|
||||||
slug: str,
|
|
||||||
*,
|
|
||||||
agent_name: str,
|
|
||||||
bottle_names: tuple[str, ...],
|
|
||||||
started_at: str,
|
|
||||||
finished_at: str,
|
|
||||||
exit_code: int,
|
|
||||||
watchdog_fired: bool = False,
|
|
||||||
egress_log_path: Path | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Return a markdown string for appending to a Gitea comment body."""
|
|
||||||
```
|
|
||||||
|
|
||||||
Output (collapsed by default):
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
<details><summary>🔬 Run provenance</summary>
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| agent | `implementer` |
|
|
||||||
| bottle | `claude` |
|
|
||||||
| slug | `implementer-abc12` |
|
|
||||||
| started | 2026-06-29T12:00:00-04:00 |
|
|
||||||
| duration | 4m 12s |
|
|
||||||
| exit | 0 ✓ |
|
|
||||||
| gitleaks | ✓ no secrets detected |
|
|
||||||
| done signal | agent comment *(or: watchdog — agent did not check in)* |
|
|
||||||
|
|
||||||
**Egress** (deny-by-default; 3 routes allowed)
|
|
||||||
- `api.anthropic.com` — Bearer auth
|
|
||||||
- `gitea.dideric.is` — Bearer auth
|
|
||||||
- `pypi.org` — unauthenticated
|
|
||||||
|
|
||||||
</details>
|
|
||||||
```
|
|
||||||
|
|
||||||
The egress summary is read from `~/.bot-bottle/state/<slug>/egress/`. When
|
|
||||||
unavailable the section is omitted. `watchdog_fired=True` changes the
|
|
||||||
"done signal" row to warn reviewers.
|
|
||||||
|
|
||||||
### Gitea client — `bot_bottle/contrib/gitea/client.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class GiteaClient:
|
|
||||||
def __init__(self, *, api_url: str) -> None: ...
|
|
||||||
def is_org_member(self, org: str, username: str) -> bool: ...
|
|
||||||
def post_comment(self, owner: str, repo: str, issue_number: int, body: str) -> None: ...
|
|
||||||
def get_pr_for_issue(self, owner: str, repo: str, issue_number: int) -> int | None: ...
|
|
||||||
def is_pr_open(self, owner: str, repo: str, pr_number: int) -> bool: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Auth is not configured in the client — the egress layer injects the token on
|
|
||||||
the way out, matching the existing `GiteaDeployKeyProvisioner` pattern.
|
|
||||||
|
|
||||||
### Implementation chunks
|
|
||||||
|
|
||||||
1. **Headless primitives** — `attach_agent_headless` + `start_headless` (with
|
|
||||||
`forge_env` param) in `cli/start.py`; `resume_headless` in `cli/resume.py`.
|
|
||||||
Tests: no tty, correct arg order, `forge_env` appears in `guest_env`.
|
|
||||||
|
|
||||||
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass,
|
|
||||||
read/write/delete/all helpers, atomic rename. Tests: round-trip JSON, missing
|
|
||||||
file → None, atomic write.
|
|
||||||
|
|
||||||
3. **Gitea client** — `contrib/gitea/client.py`: `is_org_member`,
|
|
||||||
`post_comment`, `get_pr_for_issue`, `is_pr_open`. Tests: mock
|
|
||||||
`urllib.request.urlopen`, assert payloads and 404-as-false for membership.
|
|
||||||
|
|
||||||
4. **Provenance** — `contrib/gitea/provenance.py`: `build_provenance_footer`.
|
|
||||||
Tests: required fields present, watchdog row text, egress omitted when log
|
|
||||||
absent.
|
|
||||||
|
|
||||||
5. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
|
|
||||||
`status` subcommands wired into `cli.py`. Tests: arg parsing, `start`
|
|
||||||
delegates to `start_headless`, `resume` delegates to `resume_headless`.
|
|
||||||
|
|
||||||
## Provenance as the product
|
|
||||||
|
|
||||||
Every orchestrator-posted comment ends with the provenance footer — non-optional
|
|
||||||
and not configurable off. PRs that land without a footer were not produced by
|
|
||||||
this integration. The `watchdog_fired` flag in the footer flags runs where the
|
|
||||||
agent did not self-report completion, so reviewers know the audit trail may be
|
|
||||||
incomplete.
|
|
||||||
|
|
||||||
The footer links to the bot-bottle repo pinned to the commit SHA active during
|
|
||||||
the run (not `main`), so the policy that governed the run is permanently
|
|
||||||
anchored in the PR history.
|
|
||||||
@@ -165,22 +165,6 @@ class TestAgentValidation(unittest.TestCase):
|
|||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgent.from_dict("a", {"skills": [5]}, set())
|
ManifestAgent.from_dict("a", {"skills": [5]}, set())
|
||||||
|
|
||||||
def test_skill_name_rejects_shell_metacharacters(self) -> None:
|
|
||||||
# Skill names become host/guest path segments interpolated into
|
|
||||||
# provisioning shell commands; anything outside kebab-case is
|
|
||||||
# rejected at load so it can never reach a `bottle.exec` string.
|
|
||||||
for bad in ("foo; rm -rf /", "../escape", "foo bar", "Foo", "-leading"):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
ManifestAgent.from_dict("a", {"skills": [bad]}, set())
|
|
||||||
|
|
||||||
def test_skill_name_accepts_kebab_case(self) -> None:
|
|
||||||
agent = ManifestAgent.from_dict(
|
|
||||||
"a", {"skills": ["init-entry", "quality-eval", "skill0"]}, set()
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
agent.skills, ("init-entry", "quality-eval", "skill0")
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_prompt_not_string(self) -> None:
|
def test_prompt_not_string(self) -> None:
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
||||||
|
|||||||
Reference in New Issue
Block a user