0003: Serve git-xet with capability hrefs and repo-local client config
- Status: accepted
- Date: 2026-10-23
- Scope:
git,hub,auth,ssh,client - Amends: ADR 0005, item 10 (batch details)
Context
Design §6.1 makes openxet the LFS store for Git repos hosted anywhere, through
lfs.url. Design §10.2 asks how git-xet's token refresh should work when
the LFS endpoint is openxet and the Git remote is elsewhere. Spike S7 tested this end to end with git-lfs 3.5.1 and 3.7.1 and git-xet
0.2.1. It used huggingface.co, Gitea 1.24.7 (HTTPS and SSH) and a stub
shaped on ADR 0005. The evidence is in docs/research/S7-git-lfs-xet.md and
compat/fixtures/lfs/. What was verified:
git-lfssends only thelfs.urlhost's credential to the batch API, on HTTPS and SSH remotes (observed).git-xetresolves the credential for the batchhrefwith its own lookup, separate fromgit-lfs's (source and observed).- The only client-side switch is the git config key
lfs.<endpoint derived from the remote>.access=none. With it,git-xetcalls thehrefwithout a credential, and a signed capabilityhrefalone completes the refresh and the upload (observed).git-xetdoes not read.lfsconfig, so the key must be in the clone's git config. git-xet0.2.1 registers for downloads but can't do them. A server that answersxetto a download batch breaksgit lfs pullwith no fallback (observed). huggingface.co always answersbasicfor downloads and sends noX-Xet-Hash(observed).git-xetneedshrefandexpires_inin thegit-lfs-authenticateanswer. Gitea omitsexpires_in, andgit-xetthen fails (observed).git-lfs-transferalways uses thesshadapter, soxetis never negotiated over pure SSH (source and observed).
Decision
-
Capability
hrefs. For an upload batch that offersxet, openxet answersxet. Each action'shrefis a signed, absolute capability URL for one scope and one operation, as in ADR 0005 item 10. Thehref:- needs no Authorization
- stays valid at least as long as the CAS token it returns, and for at least the transfer session
- is tested with a query-string signature, which
git-xetsends back unchanged
-
Capability routes never authenticate by header. The signature in the
hrefis the whole authorization. An Authorization header on a capability route is never used, logged or echoed, and never fails the request; audit and metrics record only that one was present. Client access is configured explicitly byopenxet setup git(decision 4). -
Downloads always answer
basic. This applies even whenxetis offered, because the server can't tellgit-xet0.2.1 from a download-capable agent of the same name.hrefs are absolute and served by the HTTP read gateway (§5.5).- The
basicdownload action'sheadercarries onlyX-Xet-Hash, which is not a secret. There is no CAS token, CAS URL or expiration. A download agent gets those fromGET {lfs-url}/xet-read-token(ADR 0005 item 1).git-lfsforwardsX-Xet-Hashto thehref, which ignores it.- Keeping the CAS token out of the action keeps it out of
GIT_TRACEoutput and out of reach of any off-host redirect of the downloadhref.
- No action
headeror response ever carries the client's own credential (§5.9 req 6). - openxet's own download agent (design §10.8) registers under its own
transfer name, not
xet. The server chooses that name when it is offered. - This replaces the download half of ADR 0005 item 10.
-
openxet setup gitwrites repo-local git config, never global, and prints exactly what it changed.- Repo hosted elsewhere (§6.1):
lfs.urllfs.<endpoint derived from the Git remote, as git-xet derives it>.access=none- the openxet access token in the git credential helper, under the openxet host only
- Repo hosted on openxet over SSH (§10.9), with
git-xetinstalled:lfs.<ssh remote url>.sshtransfer=never.- Pure SSH is only attempted when the LFS endpoint is itself SSH, so this mode is the only one affected.
- No
accesskey is set here.git-xet'sgit-lfs-authenticatecall then reaches openxet itself, which is the correct host.
It is idempotent and supports
--dry-runand--json. Docs state that each clone needs it, because.lfsconfigcan carrylfs.urlbut not theaccesskeygit-xetreads. - Repo hosted elsewhere (§6.1):
-
git-lfs-authenticate(design §10.1) returns{"href", "header": {"Authorization"}, "expires_in"}, and may addexpires_at. All three are required, becausegit-xet0.2.1 fails withouthreforexpires_in. -
git-lfs-transferstays in v1 for plaingit-lfs. It can't carryxet, sogit-xetusers of openxet-hosted repos over SSH reachxetthroughgit-lfs-authenticateplus the HTTPS batch (decision 4). -
Upstream. We contribute to
huggingface/xet-core:- a lazy credential lookup and an optional
expires_in - download support, with
direction=uploaduntil it lands
When an updated
git-xetships, decision 4 keeps working, and theaccesskey becomes unnecessary for that version. - a lazy credential lookup and an optional
Consequences
- Users of
openxet setup gitneed no credential for the refresh. Decision 2 holds for every request to a capability route, however the clone was configured. - Batch
hrefs are bearer capabilities. Their lifetime, scope (one repo, one operation) and signing key rotation are security properties. Leaking one gives that scope's CAS token until it expires. git-xetusers get no Xet downloads. They download whole files overbasicuntil our agent or upstream download support exists. This is the same as huggingface.co today.- Tests:
- Compatibility suite, M1/M2 (decisions 1, 3, 4): a Gitea-backed leg
that pushes with
git-xetand pulls withgit-lfsthroughopenxet setup git, asserting that the refresh succeeds with no Authorization header. - Integration test (decision 2): a capability
hrefrequest with a valid signature and an arbitrary Authorization header succeeds, one with an Authorization header but a bad signature fails, and no Authorization value appears in openxet's logs or responses. - Unit test: download batches never answer
xet. - Contract test (decision 5): the
git-lfs-authenticateanswer deserialises intogit-xet'sGitLFSAuthenticateResponseshape.
- Compatibility suite, M1/M2 (decisions 1, 3, 4): a Gitea-backed leg
that pushes with
jj-xet compatibility
Checked read-only against the jj-xet working tree at ~/repos/git/jj-xet
on 2026-10-23. It has no commits yet, so the line numbers are for the
working tree.
- The client is compatible with decision 3 as is.
- jj-xet sends a download batch offering
["xet","basic"](src/remote.rs:297-298). - Its response type has only
objects; it never readstransfer(src/remote.rs:342-345). - It reads only
actions.download.header["X-Xet-Hash"](src/remote.rs:321,:363-369). - It never fetches the download
href. Its CAS read token comes from{lfs-url}/xet-read-token(src/remote.rs:64-69, used forAccess::Readinsrc/xet.rs:421).
- jj-xet sends a download batch offering
- Its mock diverges, but no test depends on the difference. The
tests/openxet.rsmock still answers"transfer":"xet"(tests/openxet.rs:147) and uses a token-routehref({LFS_PATH}/xet-read-token?cap=x,tests/openxet.rs:135). To match the contract, change the mock to"transfer":"basic"and an object URLhref; the client needs no change.
Alternatives considered
- Authenticate the
hrefwith the user's openxet token. Rejected: every agent that follows thehrefwould need an openxet credential configured for it. A signed capability works without that, and keeps authorization in one place. - Use the Git host's identity for the
href. Rejected: openxet would depend on every Git host's identity system, which conflicts with "openxet never owns users". - Answer
xetfor downloads, as ADR 0005 item 10 implied. Rejected: observed to breakgit lfs pullfor everygit-xet0.2.1 user. - Wait for the upstream change alone. Rejected: released
git-xetversions need the server and setup measures to work with a separate LFS host. Those measures work today and stay correct after the change. - Disable
git-lfs-transferto force the HTTPS path. Rejected: plaingit-lfsusers on SSH-only networks lose LFS entirely. Settingsshtransfer=neverper clone givesgit-xetusers of openxet-hosted reposxetwithout taking pure SSH away from others.