S7: git-lfs / git-xet handshake, credential sourcing and SSH
- Date: 2026-10-23
- Clients:
git-lfs3.5.1 and 3.7.1 (release binaries, checksums verified)git-xet0.2.1 (taggit-xet-v0.2.1,23f68bb)- git 2.47.3
- all on Linux arm64 (
python:3.13-slim)
- Servers:
- huggingface.co
- Gitea 1.24.7 (
gitea/gitea:1.24, HTTPS and OpenSSH,LFS_ALLOW_PURE_SSH=true) - an openxet-shaped LFS stub on another host, following ADR 0005
- Source citations:
git-xetreferences are toxet-coretaggit-xet-v0.2.1.git-lfsreferences are to tagsv3.7.1andv3.5.1.- The three
git-xetbehaviours that matter here are unchanged onxet-coremainatf42483b(2026-10-23). That is checked in "Upstream drift" below.
- Questions (design §11, task brief):
- What are the exact LFS batch request and response for upload and
download against huggingface.co, with
git-xet0.2.1 and without it? - How do
git-lfs3.5.1 and 3.7.x pick credentials whenlfs.urlis on another host than the Git remote, over HTTPS and SSH? How doesgit-xetsource its refresh credential? (Design §10.2.) - Does a signed capability
hrefwith noAuthorizationheader satisfygit-xet's token refresh? (ADR 0005 item 10.) - What is the
git-lfs-authenticateresponse format, and cangit-lfs-transfer(pure SSH) negotiate thexetcustom transfer?
- What are the exact LFS batch request and response for upload and
download against huggingface.co, with
- Fixtures:
compat/fixtures/lfs/. The README there describes the format, the scenarios and the redaction.
Labels used below:
- observed: seen in the captured traffic or client output.
- source: read in the client source at the cited line.
- inferred: our conclusion, not directly verified.
Answers
- huggingface.co uses
xetfor uploads only, and its refreshhrefis the authenticated Hub token route (observed, bothgit-lfsversions).- Request:
POST {repo}.git/info/lfs/objects/batchwithAccept: application/vnd.git-lfs+jsonand body{"operation","objects":[{"oid","size"}],"transfers":[…],"ref":{"name":"refs/heads/main"},"hash_algo":"sha256"}.transfersis["lfs-standalone-file","basic","ssh"]; withgit-xetregistered,"xet"is added.- The order changes from run to run. It is a Go map iteration
(
tq/manifest.gogetAdapterNames), so servers must not depend on it. git-xetis offered for downloads too, becausegit xet installsets nodirection.
- Upload without
git-xet:"transfer":"basic".upload.hrefis a presigned S3 PUT (X-Amz-Expires=900) with no header.verify.hrefis{repo}.git/info/lfs/objects/verify.- Objects carry
"authenticated": true. There is noexpires_inand no top-levelhash_algo. - Fixtures:
hf/01-…,hf/03-….
- Upload with
git-xet:"transfer":"xet".upload.hrefishttps://huggingface.co/api/models/{ns}/{repo}/xet-write-token/refs%2Fheads%2Fmain: the Hub token route, with the URL-encoded ref as the revision.upload.headerholdsX-Xet-Cas-Url,X-Xet-Token-Expiration(unix seconds, 900 s ahead),X-Xet-Access-TokenandX-Xet-Session-Id.- The
verifyaction is still returned, andgit-lfsstill calls it aftergit-xetfinishes (200 OK). git-xetthen sends, all with the batch's CAS token:GET {cas}/v1/chunks/default/{hash}→ 404POST {cas}/v1/xorbs/default/{hash}POST {cas}/shards→{"result":1,"shard_hash":…}
- The batch token was valid for 15 minutes, so no refresh call was made.
- Fixtures:
hf/02-…,hf/04-….
- The HF refresh route accepts only
Bearer-scheme credentials (observed, probed directly on thehrefabove):- no Authorization → 401
Basicwith the token as password → 401- a foreign
Bearer→ 401 - the user's
BearerHF token → 200{accessToken, casUrl, exp}, plus theX-Xet-*headers
- Download, with or without
git-xet:"transfer":"basic"even whenxetis offered.download.hrefishttps://us.aws.cdn.hf.co/xet-bridge-us/{repo-id}/{64-hex}?…, signed by CloudFront (Expires,Policy,Signature,Key-Pair-Id) and carryinguser_id,X-Xet-Cas-Uid,xip,response-content-*andHash-Algorithm=SHA256.- There is no
headermap and noX-Xet-Hash. The 64-hex path segment differs from the oid; it is probably the Xet file hash (inferred). - Fixtures:
hf/05-…tohf/07-….
- The first batch call carries no credential and gets a 401 with two
LFS-Authenticateheaders (Basic realm=…,Bearer realm=…).git-lfsretries with Basic credentials from the credential helper.
- Request:
git-lfskeys credentials by thelfs.urlhost;git-xethas its own refresh-credential lookup, configured separately.git-lfskeys credentials by thelfs.urlhost (observed, both versions, HTTPS and SSH remotes; sourcelfsapi/auth.go:199-238in 3.7.1, identical logic at:186in 3.5.1).- It asks the credential helper for the
lfs.urlhost. Git-remote credentials are used only when the remote has the same scheme and host aslfs.url. - The stub only ever received the stub token
(
g2/s1/s2stub logs). - 3.7 adds credential-helper
capability[]=authtypeandstate[](creds/creds.go), but not a different host choice (source).
- It asks the credential helper for the
git-xetresolves the credential for its refreshhrefwith its own lookup (source,git_xet/src/auth.rs). The one switch that applies to it islfs.<endpoint derived from the remote>.access; withnone, it calls thehrefwithout a credential (question 3).- The lookup runs when each upload starts (source
xet_agent.rs:82→token_refresher.rs:36; observed), even if no refresh will be needed. Without theaccesskey and with no stored credential, the push stopped beforegit-xetcontacted thehref(g6). - Two smaller remote shapes
git-xetcan't handle (source):file://and local-path remotes:to_derived_http_schemerejects them (git_url.rs:106-118), and that runs before theaccesscheck..lfsconfig:git-xetreads only git config (git2::Repository::config,git_repo.rs:116), not.lfsconfig. So theaccesskey below must be in the clone's git config.
- Yes. A signed capability
hrefwith no Authorization satisfiesgit-xet's refresh, whenaccess=nonemakesgit-xetskip its credential lookup.- Observed: with
lfs.https://gitea:3000/alice/r5.git/info/lfs.access=noneset in the clone's git config:git-xetcalled thehrefwith no Authorization header (auth_class: null).- It parsed
{accessToken, exp, casUrl}. - It uploaded and registered the shard, and the push succeeded (
g3).
git-lfsis unaffected, because it keysaccessby thelfs.urlendpoint. The batch still used the openxet token (g3).- When the refresh fires (source
utils/src/auth.rs:11,121):git-xetrefreshes whenexp <= now + 30 s. The stub's 5-second batch token forced an immediate refresh. With a longer CAS token, thehrefis not called at all during short pushes (as on huggingface.co), but the credential lookup of question 2 still happens. - Consequence: ADR 0005 item 10 is necessary but not sufficient.
- Without the
access=nonekey,git-xetruns its own lookup (question 2) before calling thehref(g6). - openxet's policy therefore has two parts: capability routes never
authenticate by header, and
openxet setup gitconfigures access explicitly by writing the key.
- Without the
- Observed: with
git-lfs-authenticatereturns JSON.git-xetneedshrefandexpires_in.git-lfs-transfercan't negotiatexet.- Format:
- Invocation:
ssh [user@]host git-lfs-authenticate <repo-path> <upload|download>. - Answer: one JSON object,
{"href": "<LFS endpoint>", "header": {"Authorization": "REDACTED"}, "expires_in": <seconds>, "expires_at": "<RFC 3339>"}, where the Authorization value is<scheme> <token>. - The LFS spec makes
expires_inandexpires_atoptional (docs/api/authentication.md:9-29), and its example has nohref. git-lfsaddsheaderto its HTTPS batch request and useshrefas the endpoint.
- Invocation:
- Gitea 1.24.7 answers
{"header":{"Authorization":"Bearer REDACTED"},"href":"https://gitea:3000/alice/r1.git/info/lfs"}, where the value is a JWT, with no expiry fields (observed,ssh/gitea-git-lfs-authenticate-upload.json). git-xet0.2.1 requireshrefandexpires_in.- They are non-optional fields of
GitLFSAuthenticateResponse(sourcegit_xet/src/auth/ssh.rs:24-29). - Against Gitea, the upload fails with
TokenRefreshFailure("… missing field \expires_in` …")(observed,s1`). git-xetcallsgit-lfs-authenticatewithuploadon every refresh, with no caching (ssh.rs:30-33).
- They are non-optional fields of
git-lfs-transfercannot negotiatexet(source and observed, both versions).- Over pure SSH,
SSHBatchClient.Batchalways sendstransfer=sshand hard-codes the adapter namessh(tq/ssh.go:47,60in 3.7.1;:47,62in 3.5.1). - The SSH protocol carries a single
transferargument and has no offer list to choose from (docs/proposals/ssh_adapter.md:133). - With
git-xetregistered, the trace showspacket 00 > transfer=sshandtq: starting transfer adapter "ssh"(ssh/puressh-upload-lfs371.trace.txt).
- Over pure SSH,
- To get
xetwhen the LFS endpoint is SSH (openxet-hosted repos over SSH; an HTTPSlfs.urlnever uses pure SSH), pure SSH must be off or fail.git-lfstriesgit-lfs-transferfirst by default (lfs.<url>.sshtransferdefaults tonegotiate), and falls back togit-lfs-authenticateplus the HTTPS batch only when that fails (lfsapi/lfsapi.go:55-76).- With
lfs.sshtransfer=never, the push rangit-lfs-authenticateand sent an HTTPS batch offeringxet(observed,s5:ssh/sshtransfer-never-upload-lfs371.trace.txtand…gitea-flows.json). Gitea's answer has notransferfield, which meansbasicper the LFS spec, andgit-lfsstarted thebasicadapter.
- Format:
Further findings
- A server must not choose
xetfor downloads (observed,d2).git-lfsoffersxetfor downloads whenevergit-xetis installed.- If the server picks it,
git-xetanswersOperation not supported: custom transfer for download is not implemented yetand the pull fails.git-lfsdoes not fall back tobasic. - A server can't tell
git-xetfrom another agent namedxet, so every download must be answered withbasic(as huggingface.co does). - An openxet download agent therefore needs its own transfer name (design §6.1).
- Downloads work through
basicwith the right credential (observed,d1). The stub got the openxet token on the batch, and the signedbasichrefwas fetched with no Authorization. git-xetuses thehrefexactly as given (observed). Ours had a query string (?exp=…&sig=…), andgit-xetsent it unchanged. A query-signed capability URL works.
Implications for openxet
-
openxet setup gitwrites these to the clone's git config. It can't use.lfsconfigfor the second key, becausegit-xetdoesn't read it.lfs.urllfs.<endpoint-derived-from-the-remote>.access=none, with whichgit-xetcalls the capabilityhrefwithout a credential
It also stores the openxet token in the credential helper under the openxet host, for
git-lfs's batch. Repos hosted on openxet and reached over SSH (§10.9) are different: their LFS endpoint is SSH, so pure SSH applies. There it setslfs.<remote>.sshtransfer=neverwhengit-xetis installed, and noaccesskey, because the remote is openxet itself. -
Batch responses:
- For uploads offering
xet, answerxetwith a signed capabilityhref. - For every download, answer
basic, with absolutehrefs. - The
basicdownload action'sheadercarries onlyX-Xet-Hash, for agents that read it (git-lfssends it to thehref, which ignores it). It carries no CAS token: Xet download agents use{lfs-url}/xet-read-token. That keeps the token out ofGIT_TRACEoutput. - No action
headeror response ever carries the client's own credential (§5.9 req 6).
- For uploads offering
-
The capability
hrefmust:- accept requests with no Authorization
- ignore and never log any Authorization it receives
- outlive the transfer session
-
The
git-lfs-authenticateanswer includeshref,header.Authorizationandexpires_in(and may addexpires_at). Otherwisegit-xetfails against openxet-hosted repos over SSH (§10.9). -
git-lfs-transfer(§10.1) is still worth serving for plaingit-lfsusers. It only applies when the LFS endpoint is SSH, which means openxet-hosted repos over SSH; an HTTPSlfs.urlnever uses it (lfsapi/lfsapi.go:60). Its cost is that it hidesxetfromgit-xetusers there unless they setsshtransfer=never. -
These are decided in ADR 0003, which amends ADR 0005 item 10.
Upstream drift
On xet-core main at f42483b (2026-10-23), git_xet has moved to
xet_client / xet_pkg crate paths. The behaviours above are unchanged:
auth.rscredential sourcing is unchanged.token_refresher.rsnew_git_token_refresherstill resolves the credential when each upload starts.xet_agent.rs:70-72still returns "custom transfer for download is not implemented yet".auth/ssh.rs:29still haspub expires_in: u32.
No existing xet-core issue covers git-xet downloads; a search for
"git-xet download" returned nothing.
Method
- Environment:
- colima profile
openxet-spike-s7(2 CPU, 4 GiB), deleted afterwards. - Three containers on one Docker network:
- Gitea 1.24.7 over HTTPS (throwaway CA), with OpenSSH
- the stub (Python, HTTPS, same CA)
- the client (
python:3.13-slimplus git, OpenSSH client and mitmproxy 12.3.0)
- colima profile
- Isolation:
- Every run had a fresh
HOME,GIT_CONFIG_NOSYSTEM=1andGIT_TERMINAL_PROMPT=0. git-xetwas registered with the three repo-local keysgit xet installwrites (app/install.rs:43-101).- Credentials were in a repo-local
credential.helper store --file=…. - The mitmproxy CA was trusted only through
GIT_SSL_CAINFOandSSL_CERT_FILEin the container. On Linux,git-xet's rustls verifier honoursSSL_CERT_FILE(spike S2), so everygit-xetrequest was captured.
- Every run had a fresh
- Tracing:
GIT_TRACE=1 GIT_CURL_VERBOSE=1 GIT_TRANSFER_TRACE=1 GIT_TRACE_PACKET=1. - Stub:
- It logged a classification of each Authorization header, never the value, by comparing it with the known throwaway test values.
- It decoded only the non-secret claim names of JWTs.
- huggingface.co:
- One private model repo
<user>/openxet-compat-s7-<id>, deleted at the end. - The deletion was verified:
GET /api/models/…→ 404, and an authenticated listing foropenxet-compat-s7was empty. - The Hub token was only in a mode-600 file and a mode-600 credential store inside the container, both removed after the runs.
- One private model repo
- macOS: not captured, because
git-xet's rustls verifier ignoresSSL_CERT_FILEthere (S2). Credential selection is platform-independent code (inferred from the source cited above). - Redaction: a scratch script produced the fixtures with the rules in
compat/fixtures/lfs/README.md.compat/tests/test_fixture_redaction.pypasses on them.- A second scan for the exact secret values (raw and Basic-base64) and the account name found nothing.