chore: scrub private infrastructure for a public repo, rewrite README

Audited the working tree and all 26 commits of history for credentials: none
found. No API keys, no private keys, no tokens — the live bot token, webhook
secret and admin token appear nowhere in the repo or its history.

What was there was infrastructure disclosure, which is recon material rather
than a leak, but has no business in a public repo:

- Tailnet addresses and cluster-internal hostnames in code, docs and the CI
  template. The model endpoint is now supplied at runtime via
  PRAGENT_MODEL_BASE_URL and patched into opencode.json by install_config();
  the committed config carries a placeholder, guarded by a test.
- A host path (/home/marcos) as the default rtk directory — now unset.
- Real usernames in the onboarding docs — now alice/acme.
- A standing list of one-time setup tokens that were never revoked, named
  individually. Removed. Note that removing the list does not revoke the
  tokens: they should still be revoked in the Gitea admin UI.

The substitution happens in Python rather than via opencode's {env:VAR} config
templating, because the reviewer subprocess runs with an allow-listed
environment — resolving it before the process starts keeps that allow-list from
having to grow.

README rewritten for a reader who has never seen the project: what it does and
what that output looks like, honest status (pilot works, framework designed but
unbuilt), the security model up front given what this thing is, and the measured
cost numbers including the two effects that make naive estimates wrong.

History still contains the old addresses. They are tailnet-only and not
credentials, so no rewrite.

Tests: 131 -> 137.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
This commit is contained in:
Marcos
2026-08-18 12:59:52 +00:00
parent 46513585ae
commit f59b906395
11 changed files with 292 additions and 164 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ sync — the parser is tolerant but the agent and parser must agree on field nam
### Switch model / provider ### Switch model / provider
Edit `opencode.json` `provider` + `model`. The provider points at the on-network Edit `opencode.json` `provider` + `model`. The provider points at the on-network
headroom proxy (`http://100.74.17.70:8789/v1`, Anthropic `/v1/messages` format, headroom proxy (`http://<model-proxy-host>:8789/v1`, Anthropic `/v1/messages` format,
`apiKey: ollama`) → `glm-5.2:cloud`. To use a different model, add a provider and `apiKey: ollama`) → `glm-5.2:cloud`. To use a different model, add a provider and
reference it as `<provider>/<model-id>`. reference it as `<provider>/<model-id>`.
+150 -116
View File
@@ -1,143 +1,177 @@
# pragent # pragent
An extensible, forge-agnostic PR review framework. Not a product — a toolkit that teams An AI pull-request reviewer for Gitea that posts **inline comments with suggested
extend with their own review dimensions. fixes**, not a wall of prose — and reports what each review cost.
**Status:** design approved; framework build deferred. A **pilot** is live on Label a PR `AI-REVIEW`. A webhook wakes a service that checks the repo out at the
`glm-5.2:cloud` with two delivery paths: PR's head commit, reads the changed files *and the code around them*, runs the
repo's own linters, and posts a review anchored to real lines.
- **Central webhook service** (preferred, least per-repo setup): a Gitea **See it work:** [`pragent-demo` PR #1](../pragent-demo/pulls/1) — a PR with
user-level webhook posts PR events to an always-on in-cluster service that planted defects, and the review it drew: 9 findings, 3 critical, all anchored
gates on the `AI-REVIEW` label. Onboarding a repo = add `pragent-bot` inline.
collaborator + create the label + label a PR. See
[`pilot/README-webhook.md`](pilot/README-webhook.md).
- **CI-step** (legacy): a per-repo Gitea Action fetches the reviewer script at
runtime. See [`pilot/README.md`](pilot/README.md).
The framework design remains at
[`docs/plans/2026-08-04-pragent-design.md`](docs/plans/2026-08-04-pragent-design.md);
the pilot is its bootstrap and will be superseded by `pragent review` when the
framework build resumes.
## What it is
`pragent` runs as a CI step. It reads a pull request, decides how much attention the
change deserves, runs the analyzers that apply, and posts ranked findings back to the
forge.
``` ```
pragent init # one-time repo scan → .pragent/profile.yml (committed, reviewable) **[CRITICAL]** search_notes builds its SQL by string concatenation: owner and
pragent review # the CI step: tier → analyze → aggregate → publish term come from request['query'] and are spliced directly, so a term like
pragent explain # why did this PR get this tier / these findings? `' OR 1=1 --` reads every row in the table.
pragent replay # re-run a past PR against a new prompt or model (the eval loop)
pragent doctor # config, credentials, and adapter health Fix: Parameterise owner and term with placeholders and a real LIKE pattern.
🪙 ~362 tok (11% · attributed output)
``` ```
## Why not CodeRabbit / Greptile / Qodo ## Why this exists
Those are good products with fixed review dimensions and per-seat pricing. `pragent` CodeRabbit, Greptile and Qodo are good products with fixed review dimensions and
targets the case where a platform team needs to **add its own dimensions** — an internal per-seat pricing. `pragent` targets the case where a platform team needs to **add
compliance rule, a service-catalog ownership check, a house performance idiom — without its own dimensions** — an internal compliance rule, a service-catalog ownership
forking a vendor's reviewer. Cost lands in the same range (~$25/dev/month at 350 PRs/mo check, a house performance idiom — without forking a vendor's reviewer. The
for 20 devs), but the analyzers, the data, and the analytics are yours. analyzers, the data, and the analytics are yours.
## Attention tiers It is also self-hosted end to end: the model endpoint is a config value, so the
code never has to leave your network.
Every PR is classified before any expensive work happens. Deterministic rules decide ## Status
first; an ambiguous case gets one cheap model call as tie-breaker.
| Tier | What it means | Cost/PR | A **pilot** is live and reviewing real PRs. The full framework (`pragent init`,
|---|---|---| tiering as code, analyzer fan-out, `explain` / `replay`) is designed but not
| `trivial` | lockfile bumps, generated code, docs typos | ~$0.005 | built — see [`docs/plans/`](docs/plans/).
| `lite` | small change, no risk paths | ~$0.08 |
| `full` | the default for real changes | ~$0.802.00 |
| `oversized` | too big to review whole; structural summary + deep pass on the hot subset | ~$5 ceiling |
Every tier decision records *why*, so a surprising outcome is explainable rather than What works today:
mysterious.
- a central webhook service, so onboarding a repo is *add the bot + add the label*
- whole-repo context: the reviewer reads callers and types, not just the hunk
- inline comments with language-highlighted suggested fixes, anchored to
post-change lines and validated in Python before posting
- per-commit dedupe, and prior reviews fed back so a re-push synthesises rather
than repeats
- `.pr-review.json` for per-repo focus and house rules
- optional token/cost reporting via an `AI-USAGE` label
- containment against hostile PR content (see [Security](#security))
Not yet: status checks, fail-close, attention tiering enforced in code (it is
currently a skill the agent follows), multi-model routing.
## How a review runs
```
PR labelled AI-REVIEW
│ Gitea webhook (HMAC-verified, body-capped, concurrency-bounded)
review_pr()
1. dedupe already reviewed this exact sha? stop.
2. fetch diff + .pr-review.json from the BASE branch
3. checkout repo archive at head sha → temp workdir
4. sanitize delete author-controlled agent-instruction files
5. brief .pragent/brief.md, untrusted parts explicitly fenced
6. review opencode agent: read code, run linters, emit findings JSON
7. anchor validate every line against the diff's post-change lines
8. post inline comments + summary, as pragent-bot
```
Steps 1, 2, 7 and 8 are deterministic Python. The model's only job is step 6 —
producing correct findings. It never talks to Gitea, and a finding whose line
does not validate becomes a summary bullet rather than a misplaced comment.
## Setup
Onboarding a repo, once the service is running for that owner:
1. add `pragent-bot` as a **Write** collaborator
2. create the `AI-REVIEW` label
3. label a PR
Standing up the service itself — the webhook, the image, the Gitea SSRF
allow-list, the per-owner webhook registration — is in
[`pilot/README-webhook.md`](pilot/README-webhook.md). A legacy per-repo CI-step
path is in [`pilot/README.md`](pilot/README.md).
The model endpoint is supplied at runtime via `PRAGENT_MODEL_BASE_URL`; the
committed `opencode.json` carries a placeholder.
## Extending it
The review "factory" is [`.opencode/`](.opencode/README.md) — agent definitions
and skills as plain Markdown. Adding a review dimension is dropping a file in,
not writing code:
| Add | How |
|---|---|
| A review lens | `.opencode/agents/<name>.md` + one allow-list line in `pragent.md` |
| Domain knowledge | `.opencode/skills/<name>/SKILL.md`, referenced from the load table |
| Per-repo rules | `.pr-review.json` in the repo being reviewed |
Shipped skills: `attention-tiering` (the cost governor), `review-methodology`,
`findings-schema`, `linter-playbook`, `security-lens`, `malicious-change`,
`comment-craft`.
## Security
The reviewer runs an autonomous agent with shell access over a checkout of **the
PR author's branch**, and its bot account holds a Write credential. Anyone who
can open a PR can therefore put arbitrary text in front of the model and
arbitrary files on its disk — the setup exploited in the [April 2026 disclosures
against Claude Code Security Review, Gemini CLI Action and Copilot Agent][csa].
Four controls, none of which rely on the model behaving:
1. **No credentials in the agent's environment.** The subprocess environment is
built from an allow-list, not inherited. There is nothing to exfiltrate.
2. **No author-controlled instruction files on disk.** Nested `AGENTS.md`,
`CLAUDE.md`, `.cursorrules`, a repo `opencode.json` — all deleted before the
agent starts, so a PR cannot ship its own system prompt. They are still
*reviewed*, as data.
3. **Untrusted-data framing.** PR text and diffs are fenced; the agent reports
injection attempts as `critical` findings instead of following them.
4. **Reviewer config comes from the base branch**, so a PR cannot rewrite the
rules it is judged by.
Plus: tar-slip guards on the archive, a non-root container, and bounded
concurrency. Full threat model and residual risks: `pilot/README-webhook.md`.
[csa]: https://labs.cloudsecurityalliance.org/research/csa-research-note-comment-control-github-prompt-injection-20/
## What it costs ## What it costs
The pilot runs on `glm-5.2:cloud` through the on-network headroom proxy, so today it The pilot runs against a self-hosted model and bills nothing per token, but the
bills nothing per token — but the token *work* is real, and `pilot/cost_model.py` token *work* is real. `pilot/cost_model.py` prices it against published API
prices it against published API rates. Factory prompt sizes are measured from the rates, calibrated against runs measured through the `AI-USAGE` label
files in this repo; the per-tier workloads are calibrated against runs actually (`OBSERVED_RUNS` in that file — append to it, don't guess).
measured through the `AI-USAGE` label (`OBSERVED_RUNS` in that file).
**The measured anchor.** The hardening PR (`#7`, 16 files / ~1100 changed lines, Two measured reviews of a ~1100-line PR in this repo: 28 and 31 agent steps,
tier `full`) took 28 agent steps and 348s, and consumed **2,071,025 input** and ~2.1M input tokens each, **zero cache reads or writes**. The demo repo's PR, same
**17,303 output** tokens — with **zero cache reads or writes**, because the current tier: 126K tokens.
headroom/glm path does no prompt caching. Priced elsewhere, that single review is:
| Model | that review | blended per PR | 350 PRs/month | | Model | this repo, ~1100-line PR | demo repo PR |
|---|---:|---:|---:| |---|---:|---:|
| Claude Opus 5 | $10.79 | ~$4.97 | ~$1,740 | | Claude Opus 5 | ~$10.79 | ~$0.71 |
| GPT-5.6 Sol | $10.87 | ~$5.02 | ~$1,755 | | Claude Sonnet 5 | ~$4.32 | ~$0.28 |
| Claude Sonnet 5 | $4.32 | ~$1.99 | ~$696 | | Claude Haiku 4.5 | ~$2.16 | ~$0.14 |
| GPT-5.6 Terra | $4.35 | ~$2.01 | ~$702 |
| Claude Haiku 4.5 | $2.16 | ~$0.99 | ~$348 |
| GPT-5.6 Luna | $0.43 | ~$0.20 | ~$70 |
Blended figures use a 5/35/55/5 tier mix with caching off, matching what is Three things that estimate wrong if you skip them:
actually observed. Run `python3 pilot/cost_model.py --help` for other mixes and
volumes.
Two things dominate, and neither is the diff: 1. **The loop resends its context every step.** Cost is roughly quadratic in step
count, not linear in diff size. This is what `attention-tiering` exists to cap.
2. **Repository size dominates diff size.** The 16x gap above is the same
reviewer on the same tier — the difference is how much repo there was to read.
3. **Prompt caching is worth about a third of the bill** and is not currently
happening on this stack. Check `cache_read` before budgeting.
1. **The loop resends its context every step.** 28 steps over a ~17k-token diff ```bash
produced 2M input tokens. Cost is roughly quadratic in step count, which is why python3 pilot/cost_model.py --help # other mixes, volumes, models
`attention-tiering` caps steps, file reads and subagent fan-out per tier. ```
2. **Prompt caching is worth about a third of the bill** and is currently not
happening. Any move to a paid provider should confirm the `cache_read` column
goes nonzero before budgeting.
An earlier version of this model assumed 12 steps and caching on, and was ~15x ## Development
low. The lesson is in the file: budget from `OBSERVED_RUNS`, not from the tier
table, and append a row every time a real review reports usage.
## Extension points ```bash
python3 -m pytest tests -q # 137 tests, stdlib only, no network
```
Five, all documented in the design doc. Teams override or add; nobody forks. The pilot is stdlib-only Python by design — it runs from a bare `python:slim`
image with the scripts mounted, and has no dependency resolution to go wrong at
1. **Analyzers** — drop a YAML + prompt in `.pragent/analyzers/`, or install from npm review time.
2. **Forge adapters** — Gitea, GitLab, GitHub, local diff
3. **Tier policy** — thresholds and the path risk map, per repo or per org
4. **Profile enrichers** — extend what `pragent init` learns about a repo
5. **Emitter sinks** — JSONL by default, OpenTelemetry, or your own
Org config can lock keys, so a repo cannot quietly disable the security analyzer.
## Design principles
- **Polyglot by construction.** Language knowledge lives in the repo profile, not in the
reviewer. A new language is a profile change, not a core change.
- **One shared prompt prefix.** All analyzers for a PR share a byte-identical cached
prefix. This is what makes fan-out affordable; it is enforced, not hoped for.
- **Everything is traceable.** Tier reasons, token counts, cost, latency, and finding
outcomes are recorded per run. False-positive rate is measurable per analyzer.
- **Fail open.** A budget ceiling or an analyzer crash yields a partial review with a
clear note, never a blocked pipeline with no explanation.
- **The reviewed code is untrusted input.** The reviewer runs an agent over a
branch anyone with PR access can write. So it holds no credentials in its
environment, the checkout is stripped of files an agent runtime would load as
instructions, PR-authored text is fenced as data, and reviewer config is read
from the base branch. See "Threat model" in
[`pilot/README-webhook.md`](pilot/README-webhook.md).
## Stack
TypeScript + Node, built on the [`pi`](https://github.com/badlogic/pi-mono) agent SDK.
Shipped as an npm package and an OCI image, so CI runners need no local Node install.
## Roadmap
1. Walking skeleton — local diff, one analyzer, rules-only tiering
2. Gitea end to end — adapter, Woodpecker step, PR comments, status checks
3. Profile + full tier — `pragent init`, shared-prefix caching, analyzer fan-out
4. Extensibility hardening — plugin loading, config layering, `explain` / `replay`
5. Second forge — GitLab adapter, Jenkins recipe
6. Analytics maturity — OTel export, feedback loop, eval harness
## License ## License
TBD. Not yet chosen. Until one is added, no reuse rights are granted.
@@ -320,7 +320,7 @@ Each brief below goes *after* the shared context block.
- Push with the Gitea token injected at push time only — never write it into `.git/config`: - Push with the Gitea token injected at push time only — never write it into `.git/config`:
```bash ```bash
TOKEN=$(cat ~/.claude/.gitea-skills-token) TOKEN=$(cat ~/.claude/.gitea-skills-token)
git push "http://gitea_admin:${TOKEN}@100.74.17.70:30000/gitea_admin/pragent.git" main:main --tags git push "http://gitea_admin:${TOKEN}@<gitea-host>:3000/gitea_admin/pragent.git" main:main --tags
``` ```
**Definition of done:** tag `v0.1.0` exists on origin; README matches reality. **Definition of done:** tag `v0.1.0` exists on origin; README matches reality.
+6 -3
View File
@@ -8,13 +8,16 @@
"npm": "@ai-sdk/anthropic", "npm": "@ai-sdk/anthropic",
"name": "Headroom GLM", "name": "Headroom GLM",
"options": { "options": {
"baseURL": "http://100.74.17.70:8789/v1", "baseURL": "http://model-proxy.internal:8789/v1",
"apiKey": "ollama" "apiKey": "ollama"
}, },
"models": { "models": {
"glm-5.2:cloud": { "glm-5.2:cloud": {
"name": "GLM 5.2 Cloud", "name": "GLM 5.2 Cloud",
"limit": { "context": 200000, "output": 16000 } "limit": {
"context": 200000,
"output": 16000
}
} }
} }
} }
@@ -37,4 +40,4 @@
"external_directory": "allow", "external_directory": "allow",
"doom_loop": "deny" "doom_loop": "deny"
} }
} }
+13 -28
View File
@@ -39,7 +39,7 @@ ai_review.review_pr() (same core the CI-step uses)
skills, delegates to security/tests/perf subagents only on big/risky skills, delegates to security/tests/perf subagents only on big/risky
diffs, and emits: {"summary":..., "findings":[{severity,path,line, diffs, and emits: {"summary":..., "findings":[{severity,path,line,
problem,fix,suggestion,reference}]} problem,fix,suggestion,reference}]}
(=ollama: legacy single POST to http://100.74.17.70:8789/v1/messages) (=ollama: legacy single POST to http://<model-proxy-host>:8789/v1/messages)
6. parse diff hunks → valid (path, new_line) anchors (RIGHT side) 6. parse diff hunks → valid (path, new_line) anchors (RIGHT side)
7. post review → POST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot 7. post review → POST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot
- prose summary → review body intro - prose summary → review body intro
@@ -207,7 +207,7 @@ persists (`GET /admin/hooks` lists 0, no delivery). So we use **user-level
webhooks** instead — one webhook per repo-owner, which fires for every repo that webhooks** instead — one webhook per repo-owner, which fires for every repo that
user owns. For a small instance with few owners this is nearly as good. user owns. For a small instance with few owners this is nearly as good.
To onboard a new owner (e.g. `masi`): To onboard a new owner (e.g. `alice`):
```bash ```bash
# 1. generate a one-time token for that user (admin CLI, inside the gitea pod) # 1. generate a one-time token for that user (admin CLI, inside the gitea pod)
@@ -215,15 +215,15 @@ K="microk8s kubectl"
GPOD=$($K -n gitea get pod -l app=gitea --field-selector=status.phase=Running \ GPOD=$($K -n gitea get pod -l app=gitea --field-selector=status.phase=Running \
-o jsonpath='{.items[?(@.status.containerStatuses[0].ready==true)].metadata.name}') -o jsonpath='{.items[?(@.status.containerStatuses[0].ready==true)].metadata.name}')
$K -n gitea exec "$GPOD" -c gitea -- \ $K -n gitea exec "$GPOD" -c gitea -- \
gitea admin user generate-access-token --username masi \ gitea admin user generate-access-token --username alice \
--scopes write:user,read:user --token-name pragent-userhook-masi --scopes write:user,read:user --token-name pragent-userhook-alice
# 2. register the user-level webhook (events: pull_request) # 2. register the user-level webhook (events: pull_request)
# WEBHOOK_SECRET = the shared HMAC secret in the pragent-webhook K8s Secret # WEBHOOK_SECRET = the shared HMAC secret in the pragent-webhook K8s Secret
python3 - "$MASI_TOKEN" <<'PY' python3 - "$OWNER_TOKEN" <<'PY'
import sys, json, urllib.request import sys, json, urllib.request
tok = sys.argv[1] tok = sys.argv[1]
GAPI = "http://100.74.17.70:30000/api/v1" GAPI = "http://<gitea-host>:3000/api/v1"
WS = open("/dev/stdin") and __import__("os").environ["WEBHOOK_SECRET"] # or paste WS = open("/dev/stdin") and __import__("os").environ["WEBHOOK_SECRET"] # or paste
req = urllib.request.Request( req = urllib.request.Request(
f"{GAPI}/user/hooks", f"{GAPI}/user/hooks",
@@ -234,13 +234,12 @@ req = urllib.request.Request(
method="POST", headers={"Authorization":f"token {tok}","Content-Type":"application/json"}) method="POST", headers={"Authorization":f"token {tok}","Content-Type":"application/json"})
print(urllib.request.urlopen(req).status, urllib.request.urlopen(req).read()[:80]) print(urllib.request.urlopen(req).status, urllib.request.urlopen(req).read()[:80])
PY PY
# 3. revoke the one-time token (Gitea admin UI → Users → masi → Access Tokens). # 3. revoke the one-time token (Gitea admin UI → Users → <owner> → Access Tokens).
``` ```
Owners already covered: `gitea_admin` (webhook id=4), `masi` (webhook id=5), Owners are onboarded one at a time with the recipe above; true **orgs**
`techspark` (webhook id=6 — a user account, not an org; covers `techspark/suaspark-site`, need org-level webhooks (`POST /orgs/{org}/hooks`, requires a token with
`techspark/spark-ui`, etc.). True **orgs** need org-level webhooks `write:organization`).
(`POST /orgs/{org}/hooks`, requires a token with `write:organization`).
## Gitea SSRF allow-list (required, one-time) ## Gitea SSRF allow-list (required, one-time)
@@ -248,7 +247,7 @@ Gitea refuses to POST webhooks to in-cluster addresses by default:
``` ```
webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting), webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting),
deny 'pragent-webhook.pragent.svc.cluster.local(10.152.183.170:80)' deny 'pragent-webhook.pragent.svc.cluster.local(<cluster-ip>:80)'
``` ```
Fix: add a scoped `[webhook]` section to Gitea's `app.ini` via the helm chart's Fix: add a scoped `[webhook]` section to Gitea's `app.ini` via the helm chart's
@@ -305,7 +304,7 @@ direct `POST .../v1/messages` path as a fallback. opencode wants a
**provider-prefixed** model ref, so `review_pr` maps the bare `OLLAMA_MODEL` **provider-prefixed** model ref, so `review_pr` maps the bare `OLLAMA_MODEL`
(`glm-5.2:cloud`) to `headroom/glm-5.2:cloud` (override with `OPENCODE_MODEL`). (`glm-5.2:cloud`) to `headroom/glm-5.2:cloud` (override with `OPENCODE_MODEL`).
The `headroom` provider is defined in `opencode.json` with The `headroom` provider is defined in `opencode.json` with
`options.baseURL=http://100.74.17.70:8789/v1` (the headroom Anthropic proxy). `options.baseURL=http://<model-proxy-host>:8789/v1` (the headroom Anthropic proxy).
### Local one-shot (no posting) ### Local one-shot (no posting)
@@ -341,7 +340,7 @@ microk8s containerd — it is **not** pulled from a registry (`imagePullPolicy:
Never`). The webhook secret + bot token are a Secret (`pragent-webhook`). An Never`). The webhook secret + bot token are a Secret (`pragent-webhook`). An
emptyDir at `/tmp/pragent-work` holds the per-review checkout + the warmed emptyDir at `/tmp/pragent-work` holds the per-review checkout + the warmed
opencode runtime. Verified: a regular pod on kubernets reaches both opencode runtime. Verified: a regular pod on kubernets reaches both
`100.74.17.70:8789` (headroom/glm) and `gitea-http.gitea.svc.cluster.local:3000`. `<model-proxy-host>:8789` (headroom/glm) and `gitea-http.gitea.svc.cluster.local:3000`.
Build + deploy after editing the pilot scripts or the factory: Build + deploy after editing the pilot scripts or the factory:
@@ -395,17 +394,3 @@ webhook service went live.
hook delivery-history API (`.../hooks/{id}/tasks`) returns 404, so delivery is hook delivery-history API (`.../hooks/{id}/tasks`) returns 404, so delivery is
observed via the pragent-webhook pod logs (`kubectl -n pragent logs -f deploy/pragent-webhook`). observed via the pragent-webhook pod logs (`kubectl -n pragent logs -f deploy/pragent-webhook`).
## Cleanup of one-time setup tokens (manual)
Token revocation via API/CLI is broken in Gitea 1.26.1 (`GET /users/{u}/tokens`
401, no CLI `delete-access-token`). Revoke these one-time setup tokens in the
Gitea admin UI (Site Administration → Users → *user* → Manage access tokens),
name prefix `pragent-`:
- `gitea_admin`: `pragent-syswh-reg-2026`, `pragent-syswh-list-2026`,
`pragent-syswh-test-2026`, `pragent-syswh-retry-2026`,
`pragent-userhook-test-2026`, `pragent-payload-look-2026`,
`pragent-cleanup-2026`, `pragent-cleanup2-2026`, `pragent-cleanup3-2026`.
- `masi`: `pragent-userhook-masi-2026`.
- `techspark`: `pragent-userhook-techspark-2026`.
(Keep `pragent-bot`'s `pragent-ci` token — that's the live reviewer credential.)
+2 -2
View File
@@ -31,7 +31,7 @@ Or via API (with an admin/owner token):
curl -X PUT -H "Authorization: token $OWNER_TOKEN" \ curl -X PUT -H "Authorization: token $OWNER_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"permission":"write"}' \ -d '{"permission":"write"}' \
"http://100.74.17.70:30000/api/v1/repos/OWNER/REPO/collaborators/pragent-bot" "http://<gitea-host>:3000/api/v1/repos/OWNER/REPO/collaborators/pragent-bot"
``` ```
### 2. Add the `PRAGENT_BOT_TOKEN` secret ### 2. Add the `PRAGENT_BOT_TOKEN` secret
@@ -97,4 +97,4 @@ PY
| `OLLAMA_MODEL` | `glm-5.2:cloud` | Model id passed to the headroom proxy. | | `OLLAMA_MODEL` | `glm-5.2:cloud` | Model id passed to the headroom proxy. |
| `OLLAMA_MAX_TOKENS` | `6000` | Output token cap. | | `OLLAMA_MAX_TOKENS` | `6000` | Output token cap. |
| `DIFF_MAX_CHARS` | `150000` | Diff truncation cap (with a noted truncation marker). | | `DIFF_MAX_CHARS` | `150000` | Diff truncation cap (with a noted truncation marker). |
| `OLLAMA_URL` | `http://100.74.17.70:8789` | headroom proxy (tailnet). If the act-runner can't reach the tailnet IP, expose 8789 as an in-cluster Service+Endpoints and set this to the cluster DNS name. | | `OLLAMA_URL` | `http://<model-proxy-host>:8789` | headroom proxy (tailnet). If the act-runner can't reach the tailnet IP, expose 8789 as an in-cluster Service+Endpoints and set this to the cluster DNS name. |
+1 -1
View File
@@ -43,7 +43,7 @@ Env (CI run() path):
PR head); optional, defaults to the repo default branch PR head); optional, defaults to the repo default branch
PRAGENT_BOT_TOKEN bot access token (repo secret) PRAGENT_BOT_TOKEN bot access token (repo secret)
PRAGENT_SHA head SHA to tag the review PRAGENT_SHA head SHA to tag the review
OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789 OLLAMA_URL headroom proxy URL, e.g. http://model-proxy.internal:8789
OLLAMA_MODEL model id, e.g. glm-5.2:cloud OLLAMA_MODEL model id, e.g. glm-5.2:cloud
OLLAMA_MAX_TOKENS (optional) output cap, default 8000 OLLAMA_MAX_TOKENS (optional) output cap, default 8000
DIFF_MAX_CHARS (optional) diff truncation cap, default 150000 DIFF_MAX_CHARS (optional) diff truncation cap, default 150000
+40 -7
View File
@@ -37,7 +37,7 @@ Env:
PRAGENT_OPENCODE_BIN path to the opencode CLI (default: shutil.which / the PRAGENT_OPENCODE_BIN path to the opencode CLI (default: shutil.which / the
known linuxbrew path). known linuxbrew path).
PRAGENT_RTK_DIR dir holding the `rtk` binary, prepended to PATH for the PRAGENT_RTK_DIR dir holding the `rtk` binary, prepended to PATH for the
agent's bash tool (default: /home/marcos/.headroom/bin). agent's bash tool (default: unset).
PRAGENT_WORK_ROOT parent for temp workdirs (default: /tmp/pragent-work). PRAGENT_WORK_ROOT parent for temp workdirs (default: /tmp/pragent-work).
PRAGENT_KEEP_WORK if set, leave the workdir on disk for debugging. PRAGENT_KEEP_WORK if set, leave the workdir on disk for debugging.
PRAGENT_REVIEW_TIMEOUT seconds to allow opencode to run (default: 480). PRAGENT_REVIEW_TIMEOUT seconds to allow opencode to run (default: 480).
@@ -59,7 +59,7 @@ import urllib.request
# repo root (this file is at <root>/pilot/opencode_review.py). # repo root (this file is at <root>/pilot/opencode_review.py).
_DEFAULT_FACTORY = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _DEFAULT_FACTORY = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RTK_DIR = os.environ.get("PRAGENT_RTK_DIR", "/home/marcos/.headroom/bin") RTK_DIR = os.environ.get("PRAGENT_RTK_DIR", "")
WORK_ROOT = os.environ.get("PRAGENT_WORK_ROOT", "/tmp/pragent-work") WORK_ROOT = os.environ.get("PRAGENT_WORK_ROOT", "/tmp/pragent-work")
TIMEOUT = int(os.environ.get("PRAGENT_REVIEW_TIMEOUT", "480")) TIMEOUT = int(os.environ.get("PRAGENT_REVIEW_TIMEOUT", "480"))
@@ -371,14 +371,47 @@ def sanitize_workdir(workdir: str) -> list[str]:
return removed return removed
def install_config(src: str, dst: str) -> bool:
"""Copy `opencode.json` from src to dst, substituting the model endpoint.
The committed `opencode.json` carries a neutral placeholder for the model
provider's `baseURL`, so the repo can be public without publishing the
address of a private network. The real endpoint is supplied at runtime by
`PRAGENT_MODEL_BASE_URL` and patched in here.
This is done in Python rather than with opencode's own `{env:VAR}` config
templating because the reviewer subprocess runs with an allow-listed
environment (see `_build_env`) — substituting before the process starts
keeps that allow-list free of anything opencode needs to resolve config.
Returns True if a config was installed.
"""
if not os.path.isfile(src):
return False
base_url = os.environ.get("PRAGENT_MODEL_BASE_URL", "").strip()
if not base_url:
shutil.copy2(src, dst)
return True
try:
with open(src, encoding="utf-8") as f:
cfg = json.load(f)
for prov in (cfg.get("provider") or {}).values():
if isinstance(prov, dict) and isinstance(prov.get("options"), dict):
prov["options"]["baseURL"] = base_url
with open(dst, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
except (OSError, ValueError, AttributeError):
# A malformed config is opencode's problem to report, not ours to hide.
shutil.copy2(src, dst)
return True
def drop_factory(workdir: str) -> None: def drop_factory(workdir: str) -> None:
"""Copy the pragent `opencode.json` + `.opencode/` into the workdir so """Copy the pragent `opencode.json` + `.opencode/` into the workdir so
`opencode run --dir <workdir>` discovers them as project config. Overwrites `opencode run --dir <workdir>` discovers them as project config. Overwrites
any existing ones (the workdir is a throwaway archive checkout).""" any existing ones (the workdir is a throwaway archive checkout)."""
src = _factory_dir() src = _factory_dir()
oc_json = os.path.join(src, "opencode.json") install_config(os.path.join(src, "opencode.json"), os.path.join(workdir, "opencode.json"))
if os.path.isfile(oc_json):
shutil.copy2(oc_json, os.path.join(workdir, "opencode.json"))
src_oc = os.path.join(src, ".opencode") src_oc = os.path.join(src, ".opencode")
dst_oc = os.path.join(workdir, ".opencode") dst_oc = os.path.join(workdir, ".opencode")
if os.path.isdir(dst_oc): if os.path.isdir(dst_oc):
@@ -491,9 +524,9 @@ def _ensure_global_config(home: str) -> None:
src = os.path.join(_factory_dir(), "opencode.json") src = os.path.join(_factory_dir(), "opencode.json")
if not os.path.isfile(src): if not os.path.isfile(src):
return return
# Copy if missing or changed (compare mtime/size to avoid pointless writes). # Copy if missing or changed (compare mtime to avoid pointless writes).
if not os.path.isfile(dst) or os.path.getmtime(src) > os.path.getmtime(dst): if not os.path.isfile(dst) or os.path.getmtime(src) > os.path.getmtime(dst):
shutil.copy2(src, dst) install_config(src, dst)
# The ONLY host env vars forwarded to opencode. This is an allow-list, not a # The ONLY host env vars forwarded to opencode. This is an allow-list, not a
+2 -2
View File
@@ -21,7 +21,7 @@ Env:
GITEA_API in-cluster Gitea base URL GITEA_API in-cluster Gitea base URL
PRAGENT_BOT_TOKEN pragent-bot access token (non-admin; must be a Write PRAGENT_BOT_TOKEN pragent-bot access token (non-admin; must be a Write
collaborator on each reviewed repo) collaborator on each reviewed repo)
OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789 OLLAMA_URL headroom proxy URL, e.g. http://model-proxy.internal:8789
OLLAMA_MODEL model id, e.g. glm-5.2:cloud OLLAMA_MODEL model id, e.g. glm-5.2:cloud
OLLAMA_MAX_TOKENS (optional) output cap, default 6000 OLLAMA_MAX_TOKENS (optional) output cap, default 6000
DIFF_MAX_CHARS (optional) diff truncation cap, default 150000 DIFF_MAX_CHARS (optional) diff truncation cap, default 150000
@@ -57,7 +57,7 @@ AI_USAGE_LABEL = "AI-USAGE"
GITEA_API = os.environ.get("GITEA_API", "http://gitea-http.gitea.svc.cluster.local:3000") GITEA_API = os.environ.get("GITEA_API", "http://gitea-http.gitea.svc.cluster.local:3000")
BOT_TOKEN = os.environ.get("PRAGENT_BOT_TOKEN", "") BOT_TOKEN = os.environ.get("PRAGENT_BOT_TOKEN", "")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://100.74.17.70:8789") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://model-proxy.internal:8789")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "glm-5.2:cloud") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "glm-5.2:cloud")
OLLAMA_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "8000")) OLLAMA_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "8000"))
DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000")) DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000"))
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
# review_pr defaults to `opencode` for the webhook service. # review_pr defaults to `opencode` for the webhook service.
PRAGENT_ENGINE: ollama PRAGENT_ENGINE: ollama
# On-network model: headroom proxy on kubernets (tailnet IP). # On-network model: headroom proxy on kubernets (tailnet IP).
OLLAMA_URL: http://100.74.17.70:8789 OLLAMA_URL: ${{ vars.PRAGENT_MODEL_URL }}
OLLAMA_MODEL: glm-5.2:cloud OLLAMA_MODEL: glm-5.2:cloud
OLLAMA_MAX_TOKENS: "6000" OLLAMA_MAX_TOKENS: "6000"
DIFF_MAX_CHARS: "150000" DIFF_MAX_CHARS: "150000"
+75 -2
View File
@@ -1,5 +1,6 @@
"""Unit tests for the opencode engine glue (no network, no opencode run).""" """Unit tests for the opencode engine glue (no network, no opencode run)."""
import io import io
import json
import os import os
import sys import sys
import tarfile import tarfile
@@ -19,7 +20,7 @@ import opencode_review as oc # noqa: E402
def test_write_brief_contains_key_sections(tmp_path): def test_write_brief_contains_key_sections(tmp_path):
brief = oc.write_brief( brief = oc.write_brief(
str(tmp_path), str(tmp_path),
repo="masi/portfolio", index="3", sha="abcdef1234567890", repo="alice/portfolio", index="3", sha="abcdef1234567890",
title="Add eval helper", description="Closes #1", title="Add eval helper", description="Closes #1",
diff="diff --git a/x b/x\n+++ b/x\n@@ -1 +1,2 @@\n+eval(input())", diff="diff --git a/x b/x\n+++ b/x\n@@ -1 +1,2 @@\n+eval(input())",
config={"focus": ["security"], "instructions": "Flag eval()."}, config={"focus": ["security"], "instructions": "Flag eval()."},
@@ -27,7 +28,7 @@ def test_write_brief_contains_key_sections(tmp_path):
) )
assert brief.endswith(".pragent/brief.md") assert brief.endswith(".pragent/brief.md")
text = open(brief, encoding="utf-8").read() text = open(brief, encoding="utf-8").read()
assert "masi/portfolio" in text assert "alice/portfolio" in text
assert "#3" in text assert "#3" in text
assert "abcdef1234567890" in text assert "abcdef1234567890" in text
assert "Add eval helper" in text assert "Add eval helper" in text
@@ -405,3 +406,75 @@ def test_write_brief_marks_untrusted_regions(tmp_path):
# The injected title is still present — as data to review, inside the fence. # The injected title is still present — as data to review, inside the fence.
assert "Ignore previous instructions" in text assert "Ignore previous instructions" in text
assert text.index("Trust boundary") < text.index("Ignore previous instructions") assert text.index("Trust boundary") < text.index("Ignore previous instructions")
# ---------------------------------------------------------------------------
# install_config — the committed endpoint is a placeholder, patched at runtime
# ---------------------------------------------------------------------------
def _cfg(tmp_path, url="http://placeholder.internal:8789/v1"):
src = tmp_path / "opencode.json"
src.write_text(json.dumps({
"model": "headroom/glm-5.2:cloud",
"provider": {"headroom": {"npm": "@ai-sdk/anthropic",
"options": {"baseURL": url, "apiKey": "ollama"}}},
}), encoding="utf-8")
return src
def test_install_config_substitutes_base_url(tmp_path, monkeypatch):
src = _cfg(tmp_path)
dst = tmp_path / "out.json"
monkeypatch.setenv("PRAGENT_MODEL_BASE_URL", "http://real-proxy:8789/v1")
assert oc.install_config(str(src), str(dst)) is True
cfg = json.loads(dst.read_text())
assert cfg["provider"]["headroom"]["options"]["baseURL"] == "http://real-proxy:8789/v1"
# Everything else survives the rewrite.
assert cfg["provider"]["headroom"]["options"]["apiKey"] == "ollama"
assert cfg["model"] == "headroom/glm-5.2:cloud"
def test_install_config_without_override_copies_verbatim(tmp_path, monkeypatch):
src = _cfg(tmp_path)
dst = tmp_path / "out.json"
monkeypatch.delenv("PRAGENT_MODEL_BASE_URL", raising=False)
oc.install_config(str(src), str(dst))
assert json.loads(dst.read_text()) == json.loads(src.read_text())
def test_install_config_missing_source_is_a_noop(tmp_path):
assert oc.install_config(str(tmp_path / "nope.json"), str(tmp_path / "out.json")) is False
assert not (tmp_path / "out.json").exists()
def test_install_config_malformed_source_still_installs(tmp_path, monkeypatch):
src = tmp_path / "bad.json"
src.write_text("{not json", encoding="utf-8")
dst = tmp_path / "out.json"
monkeypatch.setenv("PRAGENT_MODEL_BASE_URL", "http://real-proxy:8789/v1")
assert oc.install_config(str(src), str(dst)) is True
assert dst.read_text() == "{not json" # opencode reports the parse error, not us
def test_drop_factory_applies_the_substitution(tmp_path, monkeypatch):
factory = tmp_path / "factory"
(factory / ".opencode").mkdir(parents=True)
_cfg(factory)
(factory / ".opencode" / "agents").mkdir()
workdir = tmp_path / "wd"
workdir.mkdir()
monkeypatch.setenv("PRAGENT_FACTORY_DIR", str(factory))
monkeypatch.setenv("PRAGENT_MODEL_BASE_URL", "http://real-proxy:8789/v1")
oc.drop_factory(str(workdir))
cfg = json.loads((workdir / "opencode.json").read_text())
assert cfg["provider"]["headroom"]["options"]["baseURL"] == "http://real-proxy:8789/v1"
assert (workdir / ".opencode" / "agents").is_dir()
def test_committed_config_has_no_private_address():
# Guards the public-repo scrub: the committed endpoint must stay a placeholder.
cfg = json.loads(open(os.path.join(ROOT, "opencode.json"), encoding="utf-8").read())
url = cfg["provider"]["headroom"]["options"]["baseURL"]
assert "100." not in url and "192.168." not in url, url
assert ".internal" in url or "example" in url, url