ebad90bfa9
Flip the forge-native-integration PRD from option 2 (agent calls the Gitea API directly via cred-proxy; done signal parsed from comments) to option 3 per issue #317 comment 2715: a forge sidecar backed by a Forge abstract class. - signal_done(status, summary) replaces comment-parsing as the done signal - semantic audit trail from the sidecar feeds provenance directly - read-anywhere / write-scoped enforcement, tighter than repo-wide API keys - forge-agnostic agent prompts and sidecar protocol - DeployKeyProvisioner subsumption deferred; share the HTTP client only Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
471 lines
20 KiB
Markdown
471 lines
20 KiB
Markdown
# 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 interacts with the forge through a **forge sidecar** —
|
|
the agent never touches the Gitea API or its credentials directly. The agent
|
|
calls `signal_done(status, summary)` on the sidecar when a work unit is
|
|
complete; the sidecar relays that to the orchestrator over a queue dir (the same
|
|
pattern as the supervise sidecar), so completion is an unambiguous in-band
|
|
signal rather than a comment the orchestrator has to parse. The orchestrator
|
|
freezes the bottle and attaches a provenance footer. Subsequent PR comments
|
|
rehydrate the frozen bottle. The bottle is destroyed when the PR closes.
|
|
|
|
The forge sidecar is backed by a `Forge` abstract class with per-provider
|
|
implementations (Gitea first), so the agent's prompts and the sidecar protocol
|
|
stay forge-agnostic. The sidecar logs forge operations semantically ("read PR
|
|
description", "posted comment", "signalled done"), giving richer provenance than
|
|
post-hoc egress-byte parsing, and enforces a **read-anywhere / write-scoped**
|
|
permission model: the agent may read for context but may only write to the
|
|
issue and PRs it was assigned.
|
|
|
|
The separation of concerns across the two layers: bot-bottle owns the headless
|
|
launch primitives, the forge sidecar + `Forge` abstraction, forge state, and the
|
|
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, a
|
|
forge-interaction surface the agent uses to read context, post comments, and
|
|
signal completion, and the provenance trail that makes the audit story legible
|
|
to reviewers on every PR.
|
|
|
|
That forge-interaction surface could be built two ways: (2) give the agent the
|
|
Gitea API directly with cred-proxy injecting the token, or (3) put a forge
|
|
sidecar between the agent and the forge. This PRD takes **option 3**. The
|
|
deciding factors: a sidecar `signal_done` call is an unambiguous completion
|
|
signal where comment-parsing is a correctness risk that surfaces in production;
|
|
the sidecar produces a semantic audit trail rather than HTTP bytes, which is
|
|
load-bearing for provenance (the stated product priority); and the sidecar can
|
|
enforce scope tighter than repo-wide API-key permissions, reducing blast radius
|
|
for a prompt-injected agent. The costs — a second sidecar process per forge run,
|
|
a new failure mode if it crashes, and per-forge implementation cost — are
|
|
accepted as the price of those properties.
|
|
|
|
## 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 run a **forge sidecar** that exposes a small,
|
|
forge-agnostic API (comment/issue/PR CRUD plus `signal_done`) over the same
|
|
queue-dir + HTTP/JSON-RPC machinery as the supervise sidecar. The agent calls
|
|
the sidecar; it never sees the forge token or forge-specific endpoints.
|
|
4. The sidecar is backed by a `Forge` abstract class. Gitea is the first
|
|
concrete implementation; adding a forge means a new subclass, not changes to
|
|
the agent prompt or sidecar protocol. The sidecar enforces a read-anywhere /
|
|
write-scoped model: writes are limited to the assigned issue and its PRs;
|
|
reads are unrestricted for context.
|
|
5. The agent calls `signal_done(status, summary)` on the sidecar when a work
|
|
unit is complete; the sidecar relays it to the orchestrator over a queue dir.
|
|
This is the done signal — no comment parsing. A watchdog timeout
|
|
(configurable, default 30 min) causes the orchestrator to treat the run as
|
|
done-without-self-report if the agent exits without signalling.
|
|
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, sidecar request dispatch through the `Forge`
|
|
abstraction, write-scope enforcement (reject writes outside the assigned
|
|
issue/PRs), and `signal_done` queue relay.
|
|
|
|
## 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).
|
|
- Folding `DeployKeyProvisioner` into the `Forge` abstraction. Deploy-key
|
|
provisioning runs at bottle-provision time on the host; the forge sidecar runs
|
|
inside the bottle at agent time. The two have different lifecycles and actors,
|
|
so coupling them into one class is deferred to a follow-up. This PRD only
|
|
shares the Gitea HTTP client between them.
|
|
|
|
## 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` carries the forge context and token to the forge sidecar launched
|
|
alongside the agent (see below); the agent process itself does not receive the
|
|
token. 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 sidecar
|
|
|
|
Forge-targeted bottles run a forge sidecar alongside the agent, mirroring the
|
|
supervise sidecar: a per-bottle process that exposes an HTTP/JSON-RPC endpoint
|
|
over a Unix socket and relays events to the orchestrator through a queue dir.
|
|
The agent calls the sidecar; the sidecar holds the forge token and makes the
|
|
actual forge API calls. The agent never receives the credential and never sees a
|
|
forge-specific endpoint — swapping Gitea for another forge does not change the
|
|
agent prompt or the sidecar protocol.
|
|
|
|
The sidecar is configured at launch from the forge context (owner, repo, issue,
|
|
PR) and the token, supplied by the orchestrator — not baked into the agent
|
|
manifest. Because the sidecar owns the token, forge traffic does not need a
|
|
cred-proxy egress route on the agent; the agent's egress policy is unchanged by
|
|
forge targeting.
|
|
|
|
**Sidecar protocol** (forge-agnostic; each method maps to a `Forge` call):
|
|
|
|
| Method | Scope | Purpose |
|
|
|---|---|---|
|
|
| `read_issue(number)` | read-anywhere | Read issue/PR body for context |
|
|
| `read_comments(number)` | read-anywhere | Read a thread for context |
|
|
| `post_comment(number, body)` | write-scoped | Post to the assigned issue/PR |
|
|
| `update_description(number, body)` | write-scoped | Edit the assigned issue/PR body |
|
|
| `signal_done(status, summary)` | — | Relay completion to the orchestrator |
|
|
|
|
**Scope enforcement** is read-anywhere / write-scoped: read methods accept any
|
|
issue/PR number for context; write methods are rejected unless the target is the
|
|
assigned issue or one of its PRs. This is tighter than Gitea's repo-wide API-key
|
|
permissions and bounds the blast radius of a prompt-injected agent. Rejections
|
|
are logged semantically (operation, target, reason) so the audit trail records
|
|
attempted out-of-scope writes, not just allowed ones.
|
|
|
|
**Semantic audit**: every sidecar call is logged as a structured operation
|
|
("read PR #318 description", "posted comment to #317", "signalled done:
|
|
success") rather than as opaque HTTP bytes. This log feeds provenance directly,
|
|
with no post-hoc egress-log parsing.
|
|
|
|
### `Forge` abstraction — `bot_bottle/contrib/forge/`
|
|
|
|
The sidecar dispatches to a `Forge` abstract class. Each provider implements the
|
|
operations behind the sidecar protocol:
|
|
|
|
```python
|
|
class Forge(abc.ABC):
|
|
@abc.abstractmethod
|
|
def read_issue(self, number: int) -> Issue: ...
|
|
@abc.abstractmethod
|
|
def read_comments(self, number: int) -> list[Comment]: ...
|
|
@abc.abstractmethod
|
|
def post_comment(self, number: int, body: str) -> None: ...
|
|
@abc.abstractmethod
|
|
def update_description(self, number: int, body: str) -> None: ...
|
|
@abc.abstractmethod
|
|
def is_org_member(self, org: str, username: str) -> bool: ...
|
|
@abc.abstractmethod
|
|
def get_pr_for_issue(self, number: int) -> int | None: ...
|
|
@abc.abstractmethod
|
|
def is_pr_open(self, number: int) -> bool: ...
|
|
```
|
|
|
|
`GiteaForge` is the first and only concrete implementation in this PRD. It wraps
|
|
the Gitea HTTP client (below). Adding GitHub or GitLab later is a new subclass;
|
|
the sidecar, protocol, and agent prompt are untouched.
|
|
|
|
> **Deferred:** `DeployKeyProvisioner` is *not* folded into `Forge` here.
|
|
> Deploy-key provisioning runs on the host at provision time; the sidecar runs
|
|
> in the bottle at agent time. They have different lifecycles and actors, so a
|
|
> shared abstract base would couple two unrelated auth contexts. For now they
|
|
> only share the Gitea HTTP client; a later PRD can revisit unification.
|
|
|
|
### Forge env vars
|
|
|
|
The orchestrator passes forge context to the **sidecar** (not the agent) at
|
|
launch. The agent does not need owner/repo/issue env vars to construct API
|
|
calls, since it only names issue/PR numbers to the sidecar:
|
|
|
|
| Var | Example | Purpose |
|
|
|---|---|---|
|
|
| `FORGE_GITEA_API` | `https://gitea.dideric.is/api/v1` | Base URL the sidecar calls |
|
|
| `FORGE_OWNER` | `didericis` | Repo owner |
|
|
| `FORGE_REPO` | `bot-bottle` | Repo name |
|
|
| `FORGE_ISSUE_NUMBER` | `317` | Assigned issue (defines write scope) |
|
|
| `FORGE_PR_NUMBER` | `318` | Assigned PR (empty until PR exists) |
|
|
|
|
The agent's forge-specific prompt instructs it to call `signal_done` on the
|
|
sidecar when a work unit is complete, and to use the sidecar for any
|
|
comment/description writes. The instruction is forge-agnostic and is part of the
|
|
forge prompt overlay, not the base agent manifest, so non-forge runs are
|
|
unaffected.
|
|
|
|
### Done signal and watchdog
|
|
|
|
The agent calls `signal_done(status, summary)` on the sidecar when it finishes a
|
|
work unit. The sidecar writes the event to its queue dir; the orchestrator reads
|
|
it and:
|
|
|
|
1. Reads the forge state for `(owner, repo, issue_number)`.
|
|
2. If `status == "running"`, treats the event as the done signal: freezes the
|
|
bottle, posts a summary comment with the provenance footer, sets
|
|
`status = "frozen"`.
|
|
|
|
Because completion is an explicit `signal_done` call, the orchestrator does not
|
|
parse comment text to detect "done", and intermediate comments the agent posts
|
|
mid-run cannot be mistaken for completion.
|
|
|
|
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state, updated
|
|
on each sidecar event. 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 treats the run as
|
|
done-without-self-report: it posts the provenance footer (with `watchdog_fired`
|
|
set) and freezes the bottle.
|
|
|
|
**Sidecar-death failure mode**: if the forge sidecar crashes mid-run the agent
|
|
loses forge access while the bottle is otherwise healthy. The orchestrator
|
|
detects a dead sidecar (socket/queue gone) the same way it detects a stalled
|
|
agent and falls back to the watchdog path, posting a footer that flags the
|
|
incomplete run.
|
|
|
|
### 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 | sidecar `signal_done` *(or: watchdog — agent did not signal)* |
|
|
|
|
**Egress** (deny-by-default; 2 routes allowed)
|
|
- `api.anthropic.com` — Bearer auth
|
|
- `pypi.org` — unauthenticated
|
|
|
|
Forge traffic is not an agent egress route — the forge sidecar holds the token
|
|
and makes those calls out of band. The provenance footer's forge operations come
|
|
from the sidecar's semantic audit log.
|
|
|
|
</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 HTTP client — `bot_bottle/contrib/gitea/client.py`
|
|
|
|
`GiteaForge` (and the existing `GiteaDeployKeyProvisioner`) share one thin HTTP
|
|
client. Unlike the option-2 design, the token is held by the sidecar process and
|
|
passed to the client directly — there is no agent-side cred-proxy route to
|
|
inject it, because the agent never makes forge calls.
|
|
|
|
```python
|
|
class GiteaClient:
|
|
def __init__(self, *, api_url: str, owner: str, repo: str, token: str) -> None: ...
|
|
def is_org_member(self, org: str, username: str) -> bool: ...
|
|
def post_comment(self, issue_number: int, body: str) -> None: ...
|
|
def update_comment_body(self, issue_number: int, body: str) -> None: ...
|
|
def get_pr_for_issue(self, issue_number: int) -> int | None: ...
|
|
def is_pr_open(self, pr_number: int) -> bool: ...
|
|
```
|
|
|
|
Sharing only the HTTP client (not an abstract base) is the deliberate boundary
|
|
between the sidecar and the deploy-key provisioner — see the deferral note under
|
|
the `Forge` abstraction.
|
|
|
|
### 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. **`Forge` abstraction + Gitea client** — `contrib/forge/base.py` (`Forge`
|
|
ABC) and `contrib/gitea/client.py` + `GiteaForge`: `is_org_member`,
|
|
`read_issue`, `read_comments`, `post_comment`, `update_description`,
|
|
`get_pr_for_issue`, `is_pr_open`. Tests: mock `urllib.request.urlopen`,
|
|
assert payloads and 404-as-false for membership.
|
|
|
|
4. **Forge sidecar** — sidecar process exposing the protocol over a Unix socket,
|
|
queue-dir relay, write-scope enforcement, semantic op log, `signal_done`.
|
|
Reuses the supervise sidecar bundle machinery. Tests: dispatch each method to
|
|
the `Forge`, reject out-of-scope writes, `signal_done` writes a queue event,
|
|
scope-rejection is logged.
|
|
|
|
5. **Provenance** — `contrib/gitea/provenance.py`: `build_provenance_footer`.
|
|
Tests: required fields present, watchdog row text, egress omitted when log
|
|
absent.
|
|
|
|
6. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
|
|
`status` subcommands wired into `cli.py`; `start` launches the forge sidecar
|
|
alongside the agent for forge-targeted runs. 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.
|