ADR 0025 — Origin-first forge resolution and host-keyed credentials
- Status: Accepted
- Date: 2026-07-12
- Tracking: epic #30
- Amends: 0013 (credential keying), 0007 (extends the structural workspace detection to forge selection)
Context
madtea resolves a repository’s path (owner/name) from its origin
remote, but resolves the API server from the single madtea.url config
value. The two can disagree, and when they do madtea silently queries the
wrong forge.
Observed failure (2026-07-12): a workspace holds clones from two forges —
a self-hosted Gitea (the configured madtea.url) and Codeberg. In a clone
whose origin is codeberg.org/<org>/<repo>:
push/fetch/pullwork — the git layer is host-scoped (GIT_ASKPASSoffers the credential only to the configured forge host, per ADR 0013).- Every forge-API operation fails:
prs action=createandissues action=createreturnAPI error 404: GetUserByName(the org exists on Codeberg, not Gitea), andfinishcannot resolve the default branch. The origin already names the correct forge; the API layer never consults it.
The workaround — per-directory [madtea] url via git includeIf "gitdir:" — pushes forge topology into hand-maintained gitconfig, which
the owner rejected: madtea should resolve this itself.
Separately, credential storage is keyed by the compound (url, user),
which is circular in practice: the user is needed to find the token, but
the config is needed to find the user. Multi-forge resolution needs a
lookup that starts from nothing but a host.
Decision
1. Origin-first resolution chain
Every operation resolves its forge through one ordered chain:
MADTEA_URLenv — unchanged; CI/hermetic override (ADR 0022’s guard is unaffected).- Per-repo
madtea.url— read from the target repo’s own git config; the explicit escape hatch for forges not served at their git host (subpath installs, proxies). - Origin-derived — the new default: parse the origin remote’s host
(
https://host/o/r,git@host:o/r, and ssh:// forms; explicit ports honored) and targethttps://<host>. - Global
madtea.url— fallback only when the repo has no remote (e.g. pre-pushmadtea init) and no per-repo override.
A global madtea.url no longer outranks a repo’s own origin — that
precedence inversion is the entire bug class. dir=-taking MCP tools
(finish, push, commit, worktrees) become multi-forge with no other
change: each dir brings its own origin.
2. Host-keyed credentials, one account per forge
Secure-backend entries are keyed by forge host alone; the payload is
(user, token). One account per forge is the supported model. This
removes the (url, user) circularity, and the backend itself becomes the
registry of known forges — no separate list to maintain. Existing
compound-keyed entries migrate transparently on first use.
madtea auth login becomes forge-aware and prompt-minimal:
- Inside a repo: the forge is derived from origin (“Authenticating to codeberg.org”); outside a repo it prompts for the URL.
- The username is not prompted:
GET /userwith the pasted token both discovers the login and validates the token in one round-trip. - There is no storage-scope prompt; a login is forge-wide by definition.
--globalis accepted as a deprecated no-op with a notice.
ADR 0013 is otherwise unchanged: secure backend preferred, no plaintext
without --insecure-storage, tokens never on argv, git-layer credentials
via host-scoped GIT_ASKPASS. The per-repo madtea.url override stores
only a URL — never a secret.
3. Session forge for repo-less calls
Operations with no repo to read an origin from (owner_repo-only MCP
calls, CLI invocations outside any repo) resolve a session forge,
computed lazily on first need and cached. The workspace scan is active
only when the launch/cwd is not itself inside a git repo; inside a
repo the origin chain of §1 already answers:
- Launch dir is a repo → its origin’s forge.
- Launch dir is a plain directory → scan the immediate child repos'
origins (the structural scan ADR 0007 already performs for multi-repo
detection; symlinks, bare
.git, and remoteless children are skipped) into a unique, sorted host set, then apply the tie-break below.
Tie-break (authoritative — this refines the mixed/empty bullet above; it is the verbatim rule the implementation and its tests enforce):
| Child-origin hosts | Global madtea.url | Resolution |
|---|---|---|
| Unanimous (one host) | set or unset | https://<host>, source workspace scan — outranks the global value |
| Mixed (≥ 2 hosts) | set | the global madtea.url wins (an explicit configured value breaks the tie) |
| Mixed (≥ 2 hosts) | unset | fail closed — ambiguous forge: found <h1>, <h2> — pass dir= or set madtea.url (the ErrAmbiguousForge type; hosts sorted, comma-space joined) |
| Empty (no remoteful child) | — | global madtea.url fallback |
A unanimous scan outranks the global madtea.url fallback, mirroring the
repo case (origin outranks global). A mixed scan never silently guesses:
it defers to an explicit global madtea.url when one is set, and otherwise
fails closed and names both remedies. The global value also fills in when
the scan finds nothing.
An explicit dir= on any call outranks the session default.
4. Errors name the host and the fix
- Unknown credential:
no credentials for codeberg.org — run madtea auth login(from a repo on that forge, or with the URL). - Ambiguous session forge: lists the hosts found, names both remedies.
- No operation ever falls back to a different forge than the one the chain resolved.
Consequences
- The origin/API mismatch class of bug becomes structurally impossible: an operation either targets the forge its repo names or fails with the reason.
- Behavior change (changelog-flagged): for repos with an origin, a
global
madtea.urlthat disagrees with the origin host previously won; now the origin wins. Installs that relied on that (API host differing from git host) must set the per-repo override once. - One account per forge is a deliberate constraint (owner decision, 2026-07-12). Multi-account-per-forge would reintroduce scoped lookup; nothing in this design forecloses adding a narrower scope later, but it is explicitly out of scope now.
--globalonauth loginloses meaning and is retired via deprecation notice, not error.
Testing
- Unit: chain order (env > per-repo override > origin > global); host parsing across https/ssh/scp-like/port forms; host-keyed backend round-trip and transparent migration of compound-keyed entries.
- Workspace fixture: parent dir with unanimous child origins resolves the session forge; mixed origins produce the ambiguity error verbatim.
- MCP conformance: session-default resolution and the fail-closed ambiguity path.
- The hermetic guard (ADR 0022) still refuses ambient resolution under
go test;forgejo-smokeis unchanged (MADTEA_URLenv pins it).
Amendment (2026-07-19, ref #271) - host-binding for host-agnostic token sources
The origin-first chain of section 1 resolves a repo’s forge host from its
origin remote. In a fresh clone of an untrusted repository that origin
host is therefore chosen by whoever published the repo. Host-keyed secure
backends (ADR 0013) are safe against this: each token is keyed by forge
host, so Retrieve(host) returns nothing for a host the operator never
authenticated to, and the request stays anonymous. Two token sources are
host-agnostic and carry no forge host of their own, so that keying does
not protect them:
- the
MADTEA_TOKENenvironment variable, whenMADTEA_URLis not also set (the “headless enabler”); - a plaintext
madtea.tokenin the merged git-config view (the--insecure-storagepath of ADR 0013).
Both attach an Authorization header to any same-origin request. Without a
host constraint, a networked command run inside a hostile clone would send
the real token to the attacker-controlled origin host.
Rule. A host-agnostic token attaches only when the resolved forge host matches a host the operator has recorded. A recorded host is:
- the host of
MADTEA_URL, when that variable is set; - the host of a
madtea.urlin git config, at either the repo (--local) or global (--global) scope; - for a
dir=resolution (GetConfigForDir), the host of amadtea.urlin the TARGET repo’s--localscope. The corroboration read runs in the process working directory, which cannot see the target repo’s--localscope, so that value is threaded through as an extra recorded source. The target repo’soriginremote is never a recorded source - it is clone-transferred and therefore attacker-controlled.
Git config is operator-set and is never transferred by a clone, so a
madtea.url there is a legitimate record of the operator’s expectation.
MADTEA_URL and madtea.url may both exist; any recorded host that
matches counts. Comparison is exact string equality on the bare host (with
explicit port), the same forgeHost() normalization the resolution chain
already uses.
When the resolved host is origin-derived only and matches no recorded host, resolution refuses rather than attach the token or fall back to an anonymous request that would hide the withholding. The refusal is a teaching error naming the mismatched host and three remedies, in order:
- set
MADTEA_URLto that host alongsideMADTEA_TOKEN; - record
madtea.urlfor that host in git config (repo or global scope); - run
madtea auth login.
The guard lives in internal/config/hostcorroboration.go (helper
isHostCorroborated, sentinel ErrHostNotCorroborated) so both the CLI
and MCP resolution paths inherit it. It guards only the two host-agnostic
sources; the host-keyed secure-backend path is never guarded, since it
already fails safe for unknown hosts. The rule is locked by the
TestHostCorroboration_* tests.
Uncorroborated env token, host-keyed backend present. An uncorroborated
MADTEA_TOKEN is not hard-refused at the env step; resolution falls through
to the host-keyed secure backend below it. A backend entry for the resolved
host is itself proof the operator authenticated to that forge, and the
backend fails safe for a host it never stored, so this preserves the
“host-keyed backends are unaffected” property even when an env token is also
exported (an origin-derived auth login records no madtea.url, so without
the fall-through such a user would be locked out of their own forge). Only
when no backend and no plaintext source resolves either does the withheld env
token surface the teaching refusal - the token still never reaches an
origin-derived-only host.
Plaintext login records the forge. A secure origin-derived auth login
writes no madtea.url (section 2): its host-keyed backend entry is the
record. A plaintext (--insecure-storage) login writes a host-agnostic
token that has no such record, so it now also writes a forge-wide
madtea.url naming the forge it authenticated to. Without it the operator’s
own origin-derived repo would be refused (the guard firing on the operator,
not an attacker). A global madtea.url never shadows origin in the section 1
chain and corroborates only that one forge’s host, so a later hostile clone
on a different host is still refused. This is the sole amendment to the
section 2 “origin-derived login writes no madtea.url” rule, and it applies
to the plaintext path only; the write lives in internal/cmd/setup/save.go.
Behavior change. A bare MADTEA_TOKEN with no MADTEA_URL and no
recorded madtea.url anywhere stops working against origin-derived hosts.
This is deliberate: headless setups keep working by exporting MADTEA_URL
alongside MADTEA_TOKEN (or by recording madtea.url), and a setup that
already pins its forge with MADTEA_URL or madtea auth login is
unaffected.
Canonical source: docs/adr/0025-origin-first-forge-resolution.md in the madtea repo.