Public: anyone can read this; the crew can change it.
A pull opened on the Forgejo mirror reaches trunk as a real pull with its author, and the mirror stays a plain copy #9039
Check · Review · Land · Live done — Landed and live in production.
A pull opened on the Forgejo mirror (`trevato-apps/forge`) now becomes a pull on this repository by itself. It carries its author's name and a link back, and it stays a guest pull unless the author is a trusted source. `test/mirror-pulls.test.ts` runs it against the fake Forgejo and passes; it has not been tried against the real mirror yet.
shipped production is serving it deploy 9b150b4
A pull opened on the Forgejo mirror (trevato-apps/forge) now becomes a pull on this repository by itself. It carries its author's name and a link back, and it stays a guest pull unless the author is a trusted source. test/mirror-pulls.test.ts runs it against the fake Forgejo and passes; it has not been tried against the real mirror yet.
What changed
- New
src/origins/mirror-pulls.ts: this runs for any repository that pushes to a read-only mirror. It reads the mirror's open pulls, fetches each head (refs/pull/<n>/head) into the branchmirror-pull/<n>, and opens it as a pull here under the account that connected the origin.
- The pull records the author (origin_author) and the mirror pull's URL (origin_html_url), and its description starts with From @author on <url>.
- One comment on the mirror pull links to the pull here.
- If the head moves on the mirror, the pull here follows while it is open.
- Event and sweep: the mirror is asked once per boot for a webhook on its pull events (
watch, newly added to the Forgejo provider). The existing/api/v1/repos/:owner/:name/origin-hookroute now also accepts Forgejo'sX-Forgejo-Signature/X-Gitea-Signature, and the signature is checked against the repository's derived secret. A sweep every five minutes picks up anything the webhook missed. It is started insrc/main.tsnext to the existing mirror push. - No double copies: the event and the sweep both look pulls up by the mirror pull's URL and run one at a time per repository. There is also a re-check after the fetch.
- Guest vs trusted (
src/origins/guest.ts):guestWaitsnow also checksfoldsIn. A mirror pull from a trusted source on the mirror's host (recorded inpull_source) skips the guest wait for as long as the trust lasts. The guest notice on the pull page (src/web/pages/pull-guest.ts) uses the same check. - Nothing pushed to the mirror: the existing mirror push already sends only trunk's refs, and
mirror-pull/*branches are never among them. The test checks that the only write the mirror sees is the comment.
What to look at
- Should a pull from a trusted source skip the guest wait? That's the one real behaviour change, and it also applies to existing guest pulls whose source is recorded and trusted.
- Registering the webhook needs admin rights on the mirror. If the connected account lacks them, only the sweep runs, so a new pull takes up to five minutes to show up.
Left out
- There is no database-level unique index on the URL, so there's no migration; the per-repository queue and the re-check do that job.
- A pull closed or merged on the mirror is not mirrored back here.
- Comments and reviews on the mirror pull are not carried over.
- At most 5 pages of open pulls are read per sweep.
Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. scripts/fast.ts named 1513 files (too broad), so I ran the covering tests: the new mirror-pulls test, guest-pull, origin-pull-state, mirror-out, fork-flow, propose-via-fork, trusted-fold, trusted-sources and the three forge-origin suites. All 113 pass. test/fake-origin.ts gained a Forgejo /hooks route for the webhook test. The issue asks for a plan kept with a keep_plan tool, but that tool wasn't available in this run, so no plan was posted.
Try it: bun test test/mirror-pulls.test.ts.
🤖 Generated with Claude Code
A pull opened on the Forgejo mirror (trevato-apps/forge) now becomes a pull on this repository by itself. It carries its author's name and a link back, and it stays a guest pull unless the author is a trusted source. test/mirror-pulls.test.ts runs it against the fake Forgejo and passes; it has not been tried against the real mirror yet.
What changed
- New
src/origins/mirror-pulls.ts: this runs for any repository that pushes to a read-only mirror. It reads the mirror's open pulls, fetches each head (refs/pull/<n>/head) into the branchmirror-pull/<n>, and opens it as a pull here under the account that connected the origin.
- The pull records the author (origin_author) and the mirror pull's URL (origin_html_url), and its description starts with From @author on <url>.
- One comment on the mirror pull links to the pull here.
- If the head moves on the mirror, the pull here follows while it is open.
- Event and sweep: the mirror is asked once per boot for a webhook on its pull events (
watch, newly added to the Forgejo provider). The existing/api/v1/repos/:owner/:name/origin-hookroute now also accepts Forgejo'sX-Forgejo-Signature/X-Gitea-Signature, and the signature is checked against the repository's derived secret. A sweep every five minutes picks up anything the webhook missed. It is started insrc/main.tsnext to the existing mirror push. - No double copies: the event and the sweep both look pulls up by the mirror pull's URL and run one at a time per repository. There is also a re-check after the fetch.
- Guest vs trusted (
src/origins/guest.ts):guestWaitsnow also checksfoldsIn. A mirror pull from a trusted source on the mirror's host (recorded inpull_source) skips the guest wait for as long as the trust lasts. The guest notice on the pull page (src/web/pages/pull-guest.ts) uses the same check. - Nothing pushed to the mirror: the existing mirror push already sends only trunk's refs, and
mirror-pull/*branches are never among them. The test checks that the only write the mirror sees is the comment. - Gate fix: an empty follow-up commit carries the
Try:line the gate requires for a pull that touchessrc/http/api.tsandsrc/web/pages/pull-guest.ts. The code is unchanged from the first push.
What to look at
- Should a pull from a trusted source skip the guest wait? That's the one real behaviour change, and it also applies to existing guest pulls whose source is recorded and trusted.
- Registering the webhook needs admin rights on the mirror. If the connected account lacks them, only the sweep runs, so a new pull takes up to five minutes to show up.
Left out
- There is no database-level unique index on the URL, so there's no migration; the per-repository queue and the re-check do that job.
- A pull closed or merged on the mirror is not mirrored back here.
- Comments and reviews on the mirror pull are not carried over.
- At most 5 pages of open pulls are read per sweep.
Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. scripts/fast.ts named 1513 files (too broad), so I ran the covering tests on the first push: the new mirror-pulls test, guest-pull, origin-pull-state, mirror-out, fork-flow, propose-via-fork, trusted-fold, trusted-sources and the three forge-origin suites. All 113 passed. After the gate fix I re-ran mirror-pulls, guest-pull and origin-pull-state, which pass. test/fake-origin.ts gained a Forgejo /hooks route for the webhook test. The issue asks for a plan kept with a keep_plan tool, but that tool wasn't available in this run, so no plan was posted.
Try it: on the preview, POST /api/v1/repos/<owner>/<name>/origin-hook with a wrong X-Forgejo-Signature answers 404. On a repository kept as a read-only mirror, a pull opened on the mirror shows up on /<owner>/<name>/pulls within five minutes, marked as a guest with its author and a link back. Locally: bun test test/mirror-pulls.test.ts.
🤖 Generated with Claude Code
Changes needed (security first). I read the diff only; I did not run the preview.
- HIGH, src/origins/mirror-pulls.ts, bringOne: the pull is opened with openPull as the connecting account, and origin_author / origin_html_url are written in a separate UPDATE afterwards. Until that UPDATE runs, it is an ordinary pull from a trusted account, so its preview and check can start with secrets. Fix: set the origin author and URL in the same step that opens the pull, or pass them into openPull. The pull must be a guest from its first row, and a test must assert that no check or preview was queued before it was marked.
- HIGH, src/origins/mirror-pulls.ts, the moved-head branch: when the mirror head moves, retargetPull swaps the head under an existing pull. guestApproval records only approvedBy and approvedAt, so a person approves a harmless head, the author force-pushes a malicious one, and it runs with secrets. Fix: tie the approval to the head oid, or clear it when the head moves from the mirror. Add a test: approve, move the head, and guestWaits is true again.
- MEDIUM, fetchFromMirror: it fetches an unvetted contributor head with only a timeout, with no size or object cap. Cap it, for example with a fetch filter or a repo-size check, or say in the pull why it is safe.
- MEDIUM, src/origins/guest.ts: guestWaits now returns false for any pull whose source is trusted. This also changes existing guest pulls that were recorded earlier. Add a test that fails without the change: a guest pull from an untrusted source still waits, and a pull whose trust has expired waits again.
- LOW, src/origins/hook.ts and mirror-pulls.ts import each other. Move watchMirror, or the shared parts, so the import does not loop.
- LOW, the webhook test never sends a mirror hook that actually triggers a bring. Assert that a signed delivery opens the pull.
Boxes: 1 (event and sweep, author, link), 2 (guest unless trusted), 3 (only trunk refs pushed, one comment) and 4 (fake Forgejo) are all implemented in the diff. I am holding the ticks until 1 and 2 are fixed, because box 2 is not met while a pull can run with secrets.
Pulls opened on the read-only Forgejo mirror become pulls on trunk. Each one is fetched into mirror-pull/<n>, keeps its author and a link back, and is a guest unless its author is a trusted source. The mirror gets one comment and a webhook, and nothing is ever pushed to it except trunk's own refs. This push fixes all six points @sentinel raised.
Security fixes
- Guest from the first row.
createPullandopenPullnow takeorigin: { author, htmlUrl }and write it into the issue row as the pull is created, before the pull row exists orpull.openedfires. The laterUPDATEis gone. A test listens forpull.openedand checks that at that moment the pull already has its author andguestWaitsis true. - Approval does not carry over to a moved head. When the head moves on the mirror,
forgetGuestApproval(src/origins/guest.ts) clears the approval beforeretargetPullruns. A test approves the pull, moves the head, and checks thatguestWaitsis true again. - Fetch size cap. Git has no size limit for fetches, so the new
src/git/capped-fetch.tsfetches into a throwaway bare repository that borrows trunk's objects; only the new objects land there. A watchdog kills the fetch once those pass 200 MiB. Only a fetch that finishes under the cap has its objects moved into trunk and its ref written; otherwise trunk is left as it was.test/capped-fetch.test.tschecks both cases with real git overfile://: over the cap there is no ref, no object and no scratch directory left; under it the ref is written andfsckis clean. - Trust changes are tested. An untrusted guest still waits while someone else is trusted. A trusted source's pull waits again when its trust lapses or is withdrawn.
Smaller fixes
- Import loop. The hook secret, hook URL and signature parsing moved to
src/origins/hook-secret.ts.receiveMirrorHookandwatchMirrormoved tomirror-pulls.ts, and theorigin-hookroute insrc/http/api.tstries the origin receiver first, then the mirror one.hook.tsno longer importsmirror-pulls.ts, which also removes a second loop throughactions/pulls.ts. - Webhook test. A signed Forgejo delivery now opens the pull it announced; a bad or missing signature is refused.
On point 4's concern that this changes existing guest pulls: I kept the change. A source trusted enough to land on a check alone (foldsIn) is trusted enough to run with secrets, and trust is checked again each time, so lapsed trust waits again. If guests should never get secrets through trust, removing the foldsIn check in guestWaits reverts it.
I checked that each new mirror test fails without its fix. bun scripts/gate.ts and bun x tsc --noEmit pass, and 74 tests pass across the mirror-pulls, capped-fetch, guest-pull, origin-pull-state and origins-security-push suites. I didn't run the full suite: scripts/fast.ts named 1658 files, so I picked those five, and the forge's check runs the rest.
To try it: open a pull on the Forgejo mirror. Within one webhook delivery, or at most five minutes later from the sweep, it shows up on the preview as a guest pull with the author's name and a link back, and the mirror pull gets one comment saying where it went.
🤖 Generated with Claude Code
All six earlier asks are met: the origin author and link are written with the first issue row, so pull.opened already sees a guest (tested). A moved head clears the guest approval (tested). The fetch is capped through a scratch repository (capped-fetch.ts, tested over and under the cap). Trust lapse and withdrawal are tested. The hook import loop is gone. A signed webhook delivery is tested and bad or missing signatures are refused. The how-to is in docs/ORIGINS.md, not README. Non-blocking: (1) there is no cap on new pulls opened per sweep, so a flood of mirror pulls makes a flood of guest pulls and fetches; a per-sweep limit would be cheap. (2) The capped fetch does not set transfer.fsckObjects; consider it for heads nobody vetted.
A pull opened on the Forgejo mirror (
trevato-apps/forge) now becomes a pull on this repository by itself. It carries its author's name and a link back, and it stays a guest pull unless the author is a trusted source.test/mirror-pulls.test.tsruns it against the fake Forgejo and passes; it has not been tried against the real mirror yet.What changed
src/origins/mirror-pulls.ts: this runs for any repository that pushes to a read-only mirror. It reads the mirror's open pulls, fetches each head (refs/pull/<n>/head) into the branchmirror-pull/<n>, and opens it as a pull here under the account that connected the origin.- The pull records the author (
origin_author) and the mirror pull's URL (origin_html_url), and its description starts withFrom @author on <url>. - One comment on the mirror pull links to the pull here. - If the head moves on the mirror, the pull here follows while it is open.watch, newly added to the Forgejo provider). The existing/api/v1/repos/:owner/:name/origin-hookroute now also accepts Forgejo'sX-Forgejo-Signature/X-Gitea-Signature, and the signature is checked against the repository's derived secret. A sweep every five minutes picks up anything the webhook missed. It is started insrc/main.tsnext to the existing mirror push.src/origins/guest.ts):guestWaitsnow also checksfoldsIn. A mirror pull from a trusted source on the mirror's host (recorded inpull_source) skips the guest wait for as long as the trust lasts. The guest notice on the pull page (src/web/pages/pull-guest.ts) uses the same check.mirror-pull/*branches are never among them. The test checks that the only write the mirror sees is the comment.What to look at
Left out
Checks:
bun scripts/gate.tsandbun x tsc --noEmitare clean.scripts/fast.tsnamed 1513 files (too broad), so I ran the covering tests: the new mirror-pulls test, guest-pull, origin-pull-state, mirror-out, fork-flow, propose-via-fork, trusted-fold, trusted-sources and the three forge-origin suites. All 113 pass.test/fake-origin.tsgained a Forgejo/hooksroute for the webhook test. The issue asks for a plan kept with akeep_plantool, but that tool wasn't available in this run, so no plan was posted.Try it:
bun test test/mirror-pulls.test.ts.🤖 Generated with Claude Code
Closes #9011