diff --git a/.opencode/README.md b/.opencode/README.md index 39d9ab4..788c38c 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -111,7 +111,7 @@ sync — the parser is tolerant but the agent and parser must agree on field nam ### Switch model / provider 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://:8789/v1`, Anthropic `/v1/messages` format, `apiKey: ollama`) → `glm-5.2:cloud`. To use a different model, add a provider and reference it as `/`. diff --git a/README.md b/README.md index c790dfd..164355a 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,177 @@ # pragent -An extensible, forge-agnostic PR review framework. Not a product — a toolkit that teams -extend with their own review dimensions. +An AI pull-request reviewer for Gitea that posts **inline comments with suggested +fixes**, not a wall of prose — and reports what each review cost. -**Status:** design approved; framework build deferred. A **pilot** is live on -`glm-5.2:cloud` with two delivery paths: +Label a PR `AI-REVIEW`. A webhook wakes a service that checks the repo out at the +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 - user-level webhook posts PR events to an always-on in-cluster service that - gates on the `AI-REVIEW` label. Onboarding a repo = add `pragent-bot` - 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. +**See it work:** [`pragent-demo` PR #1](../pragent-demo/pulls/1) — a PR with +planted defects, and the review it drew: 9 findings, 3 critical, all anchored +inline. ``` -pragent init # one-time repo scan → .pragent/profile.yml (committed, reviewable) -pragent review # the CI step: tier → analyze → aggregate → publish -pragent explain # why did this PR get this tier / these findings? -pragent replay # re-run a past PR against a new prompt or model (the eval loop) -pragent doctor # config, credentials, and adapter health +**[CRITICAL]** search_notes builds its SQL by string concatenation: owner and +term come from request['query'] and are spliced directly, so a term like +`' OR 1=1 --` reads every row in the table. + +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` -targets the case where a platform team needs to **add its own dimensions** — an internal -compliance rule, a service-catalog ownership check, a house performance idiom — without -forking a vendor's reviewer. Cost lands in the same range (~$25/dev/month at 350 PRs/mo -for 20 devs), but the analyzers, the data, and the analytics are yours. +CodeRabbit, Greptile and Qodo are good products with fixed review dimensions and +per-seat pricing. `pragent` targets the case where a platform team needs to **add +its own dimensions** — an internal compliance rule, a service-catalog ownership +check, a house performance idiom — without forking a vendor's reviewer. The +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 -first; an ambiguous case gets one cheap model call as tie-breaker. +## Status -| Tier | What it means | Cost/PR | -|---|---|---| -| `trivial` | lockfile bumps, generated code, docs typos | ~$0.005 | -| `lite` | small change, no risk paths | ~$0.08 | -| `full` | the default for real changes | ~$0.80–2.00 | -| `oversized` | too big to review whole; structural summary + deep pass on the hot subset | ~$5 ceiling | +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 +built — see [`docs/plans/`](docs/plans/). -Every tier decision records *why*, so a surprising outcome is explainable rather than -mysterious. +What works today: + +- 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/.md` + one allow-list line in `pragent.md` | +| Domain knowledge | `.opencode/skills//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 -The pilot runs on `glm-5.2:cloud` through the on-network headroom proxy, so today it -bills nothing per token — but the token *work* is real, and `pilot/cost_model.py` -prices it against published API rates. Factory prompt sizes are measured from the -files in this repo; the per-tier workloads are calibrated against runs actually -measured through the `AI-USAGE` label (`OBSERVED_RUNS` in that file). +The pilot runs against a self-hosted model and bills nothing per token, but the +token *work* is real. `pilot/cost_model.py` prices it against published API +rates, calibrated against runs measured through the `AI-USAGE` label +(`OBSERVED_RUNS` in that file — append to it, don't guess). -**The measured anchor.** The hardening PR (`#7`, 16 files / ~1100 changed lines, -tier `full`) took 28 agent steps and 348s, and consumed **2,071,025 input** and -**17,303 output** tokens — with **zero cache reads or writes**, because the current -headroom/glm path does no prompt caching. Priced elsewhere, that single review is: +Two measured reviews of a ~1100-line PR in this repo: 28 and 31 agent steps, +~2.1M input tokens each, **zero cache reads or writes**. The demo repo's PR, same +tier: 126K tokens. -| Model | that review | blended per PR | 350 PRs/month | -|---|---:|---:|---:| -| Claude Opus 5 | $10.79 | ~$4.97 | ~$1,740 | -| GPT-5.6 Sol | $10.87 | ~$5.02 | ~$1,755 | -| Claude Sonnet 5 | $4.32 | ~$1.99 | ~$696 | -| 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 | +| Model | this repo, ~1100-line PR | demo repo PR | +|---|---:|---:| +| Claude Opus 5 | ~$10.79 | ~$0.71 | +| Claude Sonnet 5 | ~$4.32 | ~$0.28 | +| Claude Haiku 4.5 | ~$2.16 | ~$0.14 | -Blended figures use a 5/35/55/5 tier mix with caching off, matching what is -actually observed. Run `python3 pilot/cost_model.py --help` for other mixes and -volumes. +Three things that estimate wrong if you skip them: -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 - produced 2M input tokens. Cost is roughly quadratic in step count, which is why - `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. +```bash +python3 pilot/cost_model.py --help # other mixes, volumes, models +``` -An earlier version of this model assumed 12 steps and caching on, and was ~15x -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. +## Development -## 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. - -1. **Analyzers** — drop a YAML + prompt in `.pragent/analyzers/`, or install from npm -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 +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 +review time. ## License -TBD. +Not yet chosen. Until one is added, no reuse rights are granted. diff --git a/docs/plans/2026-08-04-pragent-subagent-briefs.md b/docs/plans/2026-08-04-pragent-subagent-briefs.md index e074f42..ccfc088 100644 --- a/docs/plans/2026-08-04-pragent-subagent-briefs.md +++ b/docs/plans/2026-08-04-pragent-subagent-briefs.md @@ -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`: ```bash 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}@:3000/gitea_admin/pragent.git" main:main --tags ``` **Definition of done:** tag `v0.1.0` exists on origin; README matches reality. diff --git a/opencode.json b/opencode.json index 4637e40..61140ad 100644 --- a/opencode.json +++ b/opencode.json @@ -8,13 +8,16 @@ "npm": "@ai-sdk/anthropic", "name": "Headroom GLM", "options": { - "baseURL": "http://100.74.17.70:8789/v1", + "baseURL": "http://model-proxy.internal:8789/v1", "apiKey": "ollama" }, "models": { "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", "doom_loop": "deny" } -} \ No newline at end of file +} diff --git a/pilot/README-webhook.md b/pilot/README-webhook.md index d0199a1..7ccf8af 100644 --- a/pilot/README-webhook.md +++ b/pilot/README-webhook.md @@ -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 diffs, and emits: {"summary":..., "findings":[{severity,path,line, problem,fix,suggestion,reference}]} - (=ollama: legacy single POST to http://100.74.17.70:8789/v1/messages) + (=ollama: legacy single POST to http://:8789/v1/messages) 6. parse diff hunks → valid (path, new_line) anchors (RIGHT side) 7. post review → POST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot - 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 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 # 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 \ -o jsonpath='{.items[?(@.status.containerStatuses[0].ready==true)].metadata.name}') $K -n gitea exec "$GPOD" -c gitea -- \ - gitea admin user generate-access-token --username masi \ - --scopes write:user,read:user --token-name pragent-userhook-masi + gitea admin user generate-access-token --username alice \ + --scopes write:user,read:user --token-name pragent-userhook-alice # 2. register the user-level webhook (events: pull_request) # 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 tok = sys.argv[1] -GAPI = "http://100.74.17.70:30000/api/v1" +GAPI = "http://:3000/api/v1" WS = open("/dev/stdin") and __import__("os").environ["WEBHOOK_SECRET"] # or paste req = urllib.request.Request( f"{GAPI}/user/hooks", @@ -234,13 +234,12 @@ req = urllib.request.Request( method="POST", headers={"Authorization":f"token {tok}","Content-Type":"application/json"}) print(urllib.request.urlopen(req).status, urllib.request.urlopen(req).read()[:80]) PY -# 3. revoke the one-time token (Gitea admin UI → Users → masi → Access Tokens). +# 3. revoke the one-time token (Gitea admin UI → Users → → Access Tokens). ``` -Owners already covered: `gitea_admin` (webhook id=4), `masi` (webhook id=5), -`techspark` (webhook id=6 — a user account, not an org; covers `techspark/suaspark-site`, -`techspark/spark-ui`, etc.). True **orgs** need org-level webhooks -(`POST /orgs/{org}/hooks`, requires a token with `write:organization`). +Owners are onboarded one at a time with the recipe above; true **orgs** +need org-level webhooks (`POST /orgs/{org}/hooks`, requires a token with +`write:organization`). ## 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), -deny 'pragent-webhook.pragent.svc.cluster.local(10.152.183.170:80)' +deny 'pragent-webhook.pragent.svc.cluster.local(:80)' ``` 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` (`glm-5.2:cloud`) to `headroom/glm-5.2:cloud` (override with `OPENCODE_MODEL`). 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://:8789/v1` (the headroom Anthropic proxy). ### 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 emptyDir at `/tmp/pragent-work` holds the per-review checkout + the warmed 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`. +`:8789` (headroom/glm) and `gitea-http.gitea.svc.cluster.local:3000`. 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 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.) \ No newline at end of file diff --git a/pilot/README.md b/pilot/README.md index ca1ed36..1a965aa 100644 --- a/pilot/README.md +++ b/pilot/README.md @@ -31,7 +31,7 @@ Or via API (with an admin/owner token): curl -X PUT -H "Authorization: token $OWNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"permission":"write"}' \ - "http://100.74.17.70:30000/api/v1/repos/OWNER/REPO/collaborators/pragent-bot" + "http://:3000/api/v1/repos/OWNER/REPO/collaborators/pragent-bot" ``` ### 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_MAX_TOKENS` | `6000` | Output token cap. | | `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. | \ No newline at end of file +| `OLLAMA_URL` | `http://: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. | \ No newline at end of file diff --git a/pilot/ai_review.py b/pilot/ai_review.py index 5245b27..ca5620d 100644 --- a/pilot/ai_review.py +++ b/pilot/ai_review.py @@ -43,7 +43,7 @@ Env (CI run() path): PR head); optional, defaults to the repo default branch PRAGENT_BOT_TOKEN bot access token (repo secret) 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_MAX_TOKENS (optional) output cap, default 8000 DIFF_MAX_CHARS (optional) diff truncation cap, default 150000 diff --git a/pilot/opencode_review.py b/pilot/opencode_review.py index 59e99b8..5c1832c 100644 --- a/pilot/opencode_review.py +++ b/pilot/opencode_review.py @@ -37,7 +37,7 @@ Env: PRAGENT_OPENCODE_BIN path to the opencode CLI (default: shutil.which / the known linuxbrew path). 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_KEEP_WORK if set, leave the workdir on disk for debugging. PRAGENT_REVIEW_TIMEOUT seconds to allow opencode to run (default: 480). @@ -59,7 +59,7 @@ import urllib.request # repo root (this file is at /pilot/opencode_review.py). _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") TIMEOUT = int(os.environ.get("PRAGENT_REVIEW_TIMEOUT", "480")) @@ -371,14 +371,47 @@ def sanitize_workdir(workdir: str) -> list[str]: 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: """Copy the pragent `opencode.json` + `.opencode/` into the workdir so `opencode run --dir ` discovers them as project config. Overwrites any existing ones (the workdir is a throwaway archive checkout).""" src = _factory_dir() - oc_json = os.path.join(src, "opencode.json") - if os.path.isfile(oc_json): - shutil.copy2(oc_json, os.path.join(workdir, "opencode.json")) + install_config(os.path.join(src, "opencode.json"), os.path.join(workdir, "opencode.json")) src_oc = os.path.join(src, ".opencode") dst_oc = os.path.join(workdir, ".opencode") 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") if not os.path.isfile(src): 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): - shutil.copy2(src, dst) + install_config(src, dst) # The ONLY host env vars forwarded to opencode. This is an allow-list, not a diff --git a/pilot/webhook_server.py b/pilot/webhook_server.py index 89b0b9c..7ccbdb1 100644 --- a/pilot/webhook_server.py +++ b/pilot/webhook_server.py @@ -21,7 +21,7 @@ Env: GITEA_API in-cluster Gitea base URL PRAGENT_BOT_TOKEN pragent-bot access token (non-admin; must be a Write 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_MAX_TOKENS (optional) output cap, default 6000 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") 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_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "8000")) DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000")) diff --git a/pilot/workflow-template.yml b/pilot/workflow-template.yml index fa910a0..61ba60b 100644 --- a/pilot/workflow-template.yml +++ b/pilot/workflow-template.yml @@ -40,7 +40,7 @@ jobs: # review_pr defaults to `opencode` for the webhook service. PRAGENT_ENGINE: ollama # 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_MAX_TOKENS: "6000" DIFF_MAX_CHARS: "150000" diff --git a/tests/pilot/test_opencode_review.py b/tests/pilot/test_opencode_review.py index fc12b63..0e708d7 100644 --- a/tests/pilot/test_opencode_review.py +++ b/tests/pilot/test_opencode_review.py @@ -1,5 +1,6 @@ """Unit tests for the opencode engine glue (no network, no opencode run).""" import io +import json import os import sys import tarfile @@ -19,7 +20,7 @@ import opencode_review as oc # noqa: E402 def test_write_brief_contains_key_sections(tmp_path): brief = oc.write_brief( str(tmp_path), - repo="masi/portfolio", index="3", sha="abcdef1234567890", + repo="alice/portfolio", index="3", sha="abcdef1234567890", title="Add eval helper", description="Closes #1", diff="diff --git a/x b/x\n+++ b/x\n@@ -1 +1,2 @@\n+eval(input())", 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") text = open(brief, encoding="utf-8").read() - assert "masi/portfolio" in text + assert "alice/portfolio" in text assert "#3" in text assert "abcdef1234567890" 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. assert "Ignore previous instructions" in text 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