An account that can only read an origin repository proposes through its own fork there, and a protected repository is always proposed to that way #72

Open
tdobbertin-espo wants to merge 3 commits from tdobbertin-espo/forge:feat/propose-via-fork into main
First-time contributor

Stacked on #71 (which is stacked on #70). This branch has those two commits first. Only the top commit is new. Land #70 and #71 first.

To carry a branch into an origin repository, the forge pushed the branch there. So the account needed push rights on that repository. An account that could only read it could not propose at all.

Now such an account proposes through its own fork at the origin (src/origins/fork-route.ts). The fork is found or made. Found: in the picker's cache, by its two possible names, then by GET /repos/{o}/{r}/forks (bounded) for a fork the account owns under any name. Made: POST /forks, as <owner>-<name> when the name is taken. The branch is pushed into the fork, with a lease on where it stands there. The pull opens on the origin with head <account>:<branch>. The origin's permissions.push decides the way when the pull opens, and a direct push that the origin refuses falls back to the fork.

A repository that the operator protects (FORGE_ORIGIN_DENY_PUSH, from #71) is always proposed to through a fork, whatever the credential can do. A branch pushed into a repository runs its workflows with that repository's Actions secrets; a pull from a fork runs them without the secrets (Forgejo services/actions/secret.go). So push-through, a carried branch and a branch made on its mirror are refused there (origin_protected). The fork step of contribute uses the same fork (ensureFork), and a fork that reaches its origin through a fork says Propose via your fork. A push to the fork waits on the guard, as every other write does.

The owner:branch head is Forgejo's own form (routers/api/v1/repo/pull.go, parseCompareInfo and GetForkedRepo). The note in the Forgejo provider that called it unverified is updated. This very pull is opened that way: head tdobbertin-espo:feat/propose-via-fork, from an account with read access only on this repository (this instance runs Forgejo 15.0.6).

What is unchanged: an account with push rights on a repository that is not protected proposes as before, by a branch in the repository. Nothing new is stored, and there is no migration.

Tests

bun 1.3.13, a scrubbed environment (no agent variables):

  • test/propose-via-fork.test.ts: 10 pass, 0 fail, with a real forge serving git behind the fake API: a reader proposes through its fork with head <owner>:<branch>; the fork is reused, found under a name of its own, or made under a second name; an account that can push still pushes directly; a push the origin refuses falls back to the fork; a protected repository always takes the fork, even for an account that could push; a push into the fork waits on the guard.
  • With the guard's and #70's suites and the origin, fork, following, proposal, migrate and cross suites, in one bun test --timeout 60000 call: 544 pass, 0 fail across 41 files.
  • bun scripts/gate.ts and tsc --noEmit: clean.
the 41 files

propose-via-fork, origin-guard, origin-token-scopes, following, following-card, forks, fork-flow, fork-pull-approval, repo-fork-bar, crew-proposal, origins, origins-oauth, origins-operator, origins-reconnect, origins-security-auth, origins-security-provider, origins-security-push, origins-sync, origins-sync-settled, web-origins, web-origin-link, mcp-origins, mcp-origin-apps, push-through, trusted-hosts, trusted-hosts-ui, steps-lists, no-polling, refresh-meta, mirror-outbound, mirror-out, carried, migrate, migrate-after, tui-migrate, apps-feedback-mirror, and the five cross files reserved-names, studio, shards, check-list, hollow (each test/<name>.test.ts).

🤖 Generated with Claude Code

**Stacked on #71** (which is stacked on #70). This branch has those two commits first. Only the top commit is new. Land #70 and #71 first. To carry a branch into an origin repository, the forge pushed the branch there. So the account needed push rights on that repository. An account that could only read it could not propose at all. Now such an account proposes through its own fork at the origin (`src/origins/fork-route.ts`). The fork is found or made. Found: in the picker's cache, by its two possible names, then by `GET /repos/{o}/{r}/forks` (bounded) for a fork the account owns under any name. Made: `POST /forks`, as `<owner>-<name>` when the name is taken. The branch is pushed into the fork, with a lease on where it stands there. The pull opens on the origin with head `<account>:<branch>`. The origin's `permissions.push` decides the way when the pull opens, and a direct push that the origin refuses falls back to the fork. A repository that the operator protects (`FORGE_ORIGIN_DENY_PUSH`, from #71) is always proposed to through a fork, whatever the credential can do. A branch pushed into a repository runs its workflows with that repository's Actions secrets; a pull from a fork runs them without the secrets (Forgejo `services/actions/secret.go`). So push-through, a carried branch and a branch made on its mirror are refused there (`origin_protected`). The fork step of contribute uses the same fork (`ensureFork`), and a fork that reaches its origin through a fork says **Propose via your fork**. A push to the fork waits on the guard, as every other write does. The `owner:branch` head is Forgejo's own form (`routers/api/v1/repo/pull.go`, `parseCompareInfo` and `GetForkedRepo`). The note in the Forgejo provider that called it unverified is updated. This very pull is opened that way: head `tdobbertin-espo:feat/propose-via-fork`, from an account with read access only on this repository (this instance runs Forgejo 15.0.6). What is unchanged: an account with push rights on a repository that is not protected proposes as before, by a branch in the repository. Nothing new is stored, and there is no migration. ### Tests bun 1.3.13, a scrubbed environment (no agent variables): - `test/propose-via-fork.test.ts`: **10 pass, 0 fail**, with a real forge serving git behind the fake API: a reader proposes through its fork with head `<owner>:<branch>`; the fork is reused, found under a name of its own, or made under a second name; an account that can push still pushes directly; a push the origin refuses falls back to the fork; a protected repository always takes the fork, even for an account that could push; a push into the fork waits on the guard. - With the guard's and #70's suites and the origin, fork, following, proposal, migrate and cross suites, in one `bun test --timeout 60000` call: **544 pass, 0 fail across 41 files**. - `bun scripts/gate.ts` and `tsc --noEmit`: clean. <details><summary>the 41 files</summary> propose-via-fork, origin-guard, origin-token-scopes, following, following-card, forks, fork-flow, fork-pull-approval, repo-fork-bar, crew-proposal, origins, origins-oauth, origins-operator, origins-reconnect, origins-security-auth, origins-security-provider, origins-security-push, origins-sync, origins-sync-settled, web-origins, web-origin-link, mcp-origins, mcp-origin-apps, push-through, trusted-hosts, trusted-hosts-ui, steps-lists, no-polling, refresh-meta, mirror-outbound, mirror-out, carried, migrate, migrate-after, tui-migrate, apps-feedback-mirror, and the five cross files reserved-names, studio, shards, check-list, hollow (each `test/<name>.test.ts`). </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Connecting a token that lacks a scope names the scope, and the token form lists what a token needs
Some checks are pending
preview / preview (pull_request) Blocked by required conditions
733fd29b90
Forgejo and Gitea answer a token that lacks a required scope with 403
and a body that names the scope: `token does not have at least one of
required scope(s): [read:user]`. The API client mapped every 403 to "the
token may not do that on the origin" and did not read the body. Connect
then said "That token was refused by the origin". That is the same
sentence for a wrong token, an expired token and a narrow token, so a
person could not tell which one they had.

ApiClient now reads the body of a 403 (4 KB at most) and keeps only the
words that look like scopes (`missingScopes`). Nothing else from the
origin goes into a message. The failure carries `missingScopes`, and its
message names them as Forgejo's token page does (`scopeWords`): "the
token is missing User: Read". Sync errors say so too (for example, a
label import without the issue scope).

Connecting with such a token answers `missing_scopes` (422) with "This
token is missing User: Read." and what a token needs. `connectRefusal`
gives the API and the web form one code and one sentence for each
refused connect. An OAuth grant that lacks a scope says so, instead of
"the new token did not work".

The token form lists what a Forgejo or Gitea token needs: User: Read;
Repository and Issue: Read and write; nothing else. When the instance is
known, it links to Forgejo 15's /user/settings/applications/tokens/new
with the name and the scopes already chosen. Gitea and older Forgejo get
the list only.

A token with all its scopes, a wrong token and an expired token behave
as before.

test/origin-token-scopes.test.ts: the body parser (a matching body, an
unrelated body, junk inside the brackets), the words, connect and the
API refusing a token without read:user with nothing stored, and the
form's list and link. test/fake-origin.ts: `userScopeMissing` answers
`/user` as Forgejo does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forge keeps an origin's credential on the machine where its agents
run. So the risk is the account that a person authorized: a token of an
instance administrator, or a token that can push to a protected
repository, gives that power to every agent run.

The operator now names the hosts to guard. A host is guarded when it is
in FORGE_ORIGIN_CLIENT_ID, or when a FORGE_ORIGIN_DENY_PUSH rule covers
it (`owner/name` patterns, `*` inside one part, a bare `owner` for all
its repositories, `host:pattern` for one host). On a guarded host, a
credential is refused before it is stored when its account administers
the instance (`is_admin` / `site_admin`) or can push to a protected
repository (`permissions.push`).

- The guard runs on each way in: a pasted token, the code flow (the
  grant is revoked where the provider can do it) and the device flow.
  A refusal stores nothing.
- A repository named outright is asked about by name. A wildcard walks
  /repos/search?uid= sorted by id, with no fallback, at most 20 pages.
  The walk is complete only when the distinct repositories it saw equal
  X-Total-Count, and that count did not change between pages. Anything
  less fails closed (`guard_unchecked`). A confirmed hit always wins
  (`can_push`).
- A stored origin is checked again: on each OAuth refresh, before the
  new token is used, and by a sweep on the sync tick (an origin never
  checked at once, the others daily, one origin per tick). A refusal
  sets needs_reauth with the guard's sentence. A refresh never clears
  it.
- On a guarded host, an origin writes only after its account passed.
  Until then, or while a check cannot run, write-through, push-through,
  carried branches, forks and contributions answer `origin_paused`, and
  the adapter refuses each request that is not a read. Settings →
  Origins says so and has Check again (POST /settings/origins/:id/check,
  once a minute). A check that has not run for a day refuses the origin.
- The walk fills the repository picker's cache at connect.

A host that is not guarded behaves as before. With neither variable
set, no host is guarded, so a person who runs their own Forgejo can
still connect it as its administrator.

docs/ORIGINS.md, "The operator guard"; test/origin-guard.test.ts
(against the fake origin, with a real forge serving git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
To carry a branch into an origin repository, the account needed push
rights there. An account that could only read the repository could not
propose at all.

Now such an account proposes through its own fork at the origin
(src/origins/fork-route.ts). The fork is found (in the picker's cache,
by its two possible names, then by GET /repos/{o}/{r}/forks, bounded,
for a fork the account owns under any name) or made (POST /forks, as
`<owner>-<name>` when the name is taken). The branch is pushed into the
fork, with a lease on where it stands there. The pull opens on the
origin with head `<account>:<branch>`. The origin's `permissions.push`
decides the way when the pull opens, and a direct push that the origin
refuses falls back to the fork.

A repository that the operator protects (FORGE_ORIGIN_DENY_PUSH) is
always proposed to through a fork, whatever the credential can do: a
branch pushed into a repository runs its workflows with that
repository's Actions secrets, and a pull from a fork runs them without
the secrets (Forgejo services/actions/secret.go:43). So push-through, a
carried branch and a branch made on its mirror are refused there
(`origin_protected`). The fork step of contribute uses the same fork
(`ensureFork`), and a fork that reaches its origin through a fork says
"Propose via your fork". A push to the fork waits on the guard, as
every other write does.

The `owner:branch` head is Forgejo's own form (routers/api/v1/repo/
pull.go, parseCompareInfo and GetForkedRepo). It was checked on Forgejo
15.0.9: an account with read access only opened pulls on a private
repository through its fork, and the repository got no new branch. The
note in the Forgejo provider that called this form unverified is
updated.

An account with push rights on a repository that is not protected
proposes as before.

test/propose-via-fork.test.ts (a real forge serving git behind the fake
API).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some checks are pending
preview / preview (pull_request) Blocked by required conditions
This pull request has changes conflicting with the target branch.
  • README.md
  • src/actions/branches.ts
  • src/actions/contribute.ts
  • src/actions/origins.ts
  • src/actions/pulls.ts
  • src/http/api.ts
  • src/http/server.ts
  • src/main.ts
  • src/origins/credential.ts
  • src/origins/fork-route.ts
  • src/origins/providers/forgejo.ts
  • src/origins/providers/github.ts
  • src/origins/push-through.ts
  • src/origins/write-through.ts
  • src/tui/screens/common.ts
  • src/web/pages/branches.ts
  • src/web/pages/browse.ts
  • src/web/pages/edit.ts
  • src/web/pages/following.ts
  • src/web/pages/origins.ts
  • src/web/pages/thread.ts
  • test/fake-origin.ts
  • test/origin-guard.test.ts
  • test/origin-token-scopes.test.ts
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u feat/propose-via-fork:tdobbertin-espo-feat/propose-via-fork
git switch tdobbertin-espo-feat/propose-via-fork

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff tdobbertin-espo-feat/propose-via-fork
git switch tdobbertin-espo-feat/propose-via-fork
git rebase main
git switch main
git merge --ff-only tdobbertin-espo-feat/propose-via-fork
git switch tdobbertin-espo-feat/propose-via-fork
git rebase main
git switch main
git merge --no-ff tdobbertin-espo-feat/propose-via-fork
git switch main
git merge --squash tdobbertin-espo-feat/propose-via-fork
git switch main
git merge --ff-only tdobbertin-espo-feat/propose-via-fork
git switch main
git merge tdobbertin-espo-feat/propose-via-fork
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
trevato-apps/forge!72
No description provided.