auth: defer credential helper until 401, match git's behaviour#65
Conversation
git-sync used to call `git credential fill` proactively whenever an HTTP endpoint had no explicit auth. Two problems with that: - On hosts the user had never authenticated against, git fell back to an interactive `Username:`/`Password:` prompt — turning git-sync into an interactive command and breaking non-interactive runs (issue #63). - For hosts where the helper *did* have credentials, we'd send a token to public repos that didn't need one — leaking a credential to a request the server hadn't actually challenged. This change makes git-sync follow git's own HTTP auth flow: - `auth.Resolve` no longer consults the credential helper. Anonymous (or explicit token / Entire DB token) is what comes back. - `HTTPConn` gains a `CredentialHelper` interface. On a 401 it calls `Lookup`, retries the request with the returned credentials, and stores the auth on the conn so follow-up `PostRPC` calls reuse it. - `auth.GitCredentialHelper` shells out to `git credential fill / approve / reject` with `GIT_TERMINAL_PROMPT=0`, so a misconfigured helper fails fast rather than blocking on a tty prompt. - On a successful retry we tell the helper `approve`; on 401 *or* 403 (Cloudflare-style "Invalid or expired token") we tell it `reject` so stale credentials self-heal across runs. Tests cover the full lifecycle: anonymous success skips the helper, 401 triggers Lookup + retry + Approve, retry-still-401 and retry-403 both trigger Reject, helper-with-no-credentials surfaces the original 401 cleanly, and explicit auth disables the helper fallback entirely. Closes #63. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 84b7af2c6388
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.
| c.CredentialHelper.Reject(ctx, c.EndpointURL, user, pass) | ||
| case res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices: | ||
| c.Auth = retryAuth | ||
| c.CredentialHelper.Approve(ctx, c.EndpointURL, user, pass) |
There was a problem hiding this comment.
Premature credential helper approve
Medium Severity
After a 401 retry, Approve and c.Auth are applied as soon as the retry HTTP status is 2xx, before httpError completes the success path and before the response is checked for the expected Git advertisement content-type or read/size limits. A misleading 2xx (wrong type or body) can persist credentials in the helper and on the connection even though RequestInfoRefs still returns an error.
Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.
| // Helper exited non-zero — typically means "no credentials found" | ||
| // or "terminal prompts disabled" (when no helper has creds). Treat | ||
| // both as "no credentials available" so the original 401 surfaces. | ||
| return "", "", false, nil //nolint:nilerr // intentional: swallow helper failure as "no credentials" |
There was a problem hiding this comment.
Cancel masked as HTTP 401
Medium Severity
After a 401, GitCredentialHelper.Lookup runs git credential fill with the sync request context. If that context is cancelled or times out while the subprocess runs, every failure from GitCredentialCommand is turned into ok=false, so RequestInfoRefs falls through and reports the original HTTP 401 instead of context.Canceled or context.DeadlineExceeded.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.
- Drop internal basicAuth duplicate; use transporthttp.BasicAuth directly in the gitproto retry path (the package already pulls in go-git types via the AuthMethod interface contract). - Collapse GitCredentialHelper.Approve/Reject into a single signal() helper; they only differed in the op string. - Type the credential op as auth.CredentialOp with named constants (CredentialOpFill / Approve / Reject), removing magic strings from production code and the integration test switch. - Collapse fakeCredentialHelper's six counters/last-X fields into a single calls []credCall slice with count(op)/last(op) accessors. - Add a newTestConn(t, rt) helper to deduplicate the 8 new tests. - Trim narrating comments throughout (per code review). Kept the CredentialHelper interface contract, the GIT_TERMINAL_PROMPT=0 rationale, the 403-Cloudflare anecdote, and the explicit-auth-wins invariant — these encode non-obvious WHY. Trimmed the rest. No behaviour change; full test suite + race detector + golangci-lint pass clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 4f0fc9b99fdc
Two correctness gaps caught in review: 1. After a 3xx on /info/refs, the host that actually returns 401 is in res.Request.URL, which can differ from c.EndpointURL. Querying the helper with c.EndpointURL fetched (and on success approved) credentials under the wrong key — a miss on the next run, or a poisoned key if the retry happened to work via a follow-up redirect. New challengeURLFor() preserves the original repo path but swaps in the post-redirect scheme/host before consulting the helper. 2. Helper fallback existed only on GET /info/refs. Servers that allow anonymous discovery but require auth on the actual pack POST (e.g. Gerrit anonymous-readable + authenticated push) would fail hard. PostRPCStreamBody now does the same lookup → retry → approve/reject dance, gated on the body being io.Seeker so we can rewind it for the second attempt. PostRPC / PostRPCStream always pass bytes.NewReader, which is seekable; a caller that hands in a raw non-seekable Reader sees the 401 surface as-is (documented in the doc comment). The retry lifecycle is now in tryHelperRetry, shared between GET and POST. Four new tests cover redirect-host keying and the POST 401-retry permutations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 991254d4f73a
The previous commit's io.Seeker gate didn't help the production push path. internal/gitproto/push.go builds the receive-pack body as io.MultiReader(header, packData), where packData is the live pipe from upload-pack. That body satisfies neither io.Seeker nor any other replayable contract, so PostRPCStreamBody's on-the-fly 401 retry was a no-op for real pushes. Fix: add HTTPConn.EnsureAuthForService(ctx, service) and call it from sendReceivePack before the body is constructed. It issues an anonymous GET to /<service>; if the server 401s, the helper is consulted, retried with credentials, and (on a non-401/403 response) c.Auth is stored so the streaming POST that follows is pre-authenticated. The probe handles 2xx, 404, 405 etc. all as "credentials accepted" — any non-401/403 means the server didn't reject the creds we sent. 405 is the typical response for GET /git-receive-pack on smart-HTTP servers, so this is the common shape. Limitation made explicit in the doc comment: servers that allow GET anonymously but only 401 on POST will slip past this probe. For those, callers must pass explicit credentials (--target-token). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: f6b8e1586278
The previous probe logic ran a second authenticated GET /<service> and approved the credentials whenever that response wasn't 401/403. But many servers return 405 to GET /git-receive-pack without ever validating the Authorization header — so a 405 with attached credentials proves nothing. Approving in that case was a false positive: stale helper creds got blessed, c.Auth was set, and the streaming POST that followed skipped the helper retry path. The push then failed with no way to recover the bad helper state. Fix: the probe is now strictly anonymous. Its only job is to detect whether the server requires auth here (the 401 signal). If it does, the helper-supplied credentials are attached to c.Auth tentatively and recorded as pendingHelperCreds. The next real operation (PostRPCStreamBody or RequestInfoRefs) calls resolvePendingHelperCreds on its response: Approve on 2xx, Reject + clear c.Auth on 401/403, no-op otherwise. So helper state only changes when the real operation provides a definitive signal — which is what git itself does. Six new/updated tests: - TentativelyAttachesHelperCredsOnAnonymous401: c.Auth set, no Approve - 405ProbeWithCredsDoesNotPoisonHelper: explicit regression for the bug - RealPostApprovesTentativeCreds: 2xx on the real POST → Approve - RealPostRejectsTentativeCreds: 401 on the real POST → Reject + clear - NoHelperIsNoOp, AnonymousServiceLeavesAuthNil: existing semantics preserved Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: ce0a3f0780c7
GET /git-receive-pack doesn't reliably exercise the same auth path as the real push. Servers that 404/405 the GET method while requiring auth on POST were slipping past EnsureAuthForService — the streaming push then 401'd with no way to retry. Cloudflare-style "Invalid or expired token" servers fall in this bucket, as do some Gerrit configurations. Two changes: 1. Probe with POST and the smart-HTTP flush packet "0000" as body — a valid no-op (zero ref updates, zero pack data) by spec. The auth layer challenges this POST identically to the real push, so the 401 signal is reliable. On the anonymous-allowed case the server processes a no-op that touches no ref state. 2. Lookup helper credentials *before* probing, and skip the probe entirely when the helper has nothing to attach. Without this every anonymous sync would do a wasted no-op POST per push. With it, only syncs that have a configured helper with stored credentials for the host pay the round-trip cost. cmd/git-sync's TestMain now overrides auth.GitCredentialCommand with a "no helper configured" stub. Without isolation, the developer's local credential store (osxkeychain etc.) could return cached credentials for 127.0.0.1 left over from a previous test run, turning the probe into a real POST and inflating receive-pack POST counts. The internal/syncer integration tests are unaffected — their server filters by metricPack which the probe's no-op body doesn't carry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 47657919e49a


Summary
Fixes #63. Adopts git's own HTTP auth flow: try anonymous first, only consult the credential helper if the server returns 401.
auth.Resolveproactively rangit credential fillfor every HTTPS endpoint with no explicit auth. On hosts the user had never authenticated against, git fell back to an interactiveUsername:/Password:prompt — turning git-sync into an interactive command. And for hosts where the helper did have credentials, we'd leak a token to public repos that didn't need one.CredentialHelper.Lookupis consulted; on success we retry once, store the auth on the conn for follow-upPostRPCcalls, and tell the helper toapprovethe credentials. TheGitCredentialHelperimplementation runs git withGIT_TERMINAL_PROMPT=0, so a misconfigured helper fails fast rather than blocking on a tty prompt.What about expired tokens?
If the helper's stored credentials have expired, the retry returns 401 (or 403 for token services like Cloudflare that surface "Invalid or expired token" as 403 instead of 401). In both cases we call
Helper.Rejectso the helper forgets the stale credentials and the next run starts clean.The 403 path is deliberately scoped to the retry result only — never the initial response. A 403 on a first anonymous request usually means "this repo is forbidden to you" rather than "your credentials are bad", and we don't want to risk dropping good credentials for an archived/restricted repo.
Test plan
go test ./...passesgo test -raceoninternal/{gitproto,auth,syncer}golangci-lint runcleanauthtests coverGitCredentialHelper.{Lookup,Approve,Reject}and theGIT_TERMINAL_PROMPT=0env contractTestRun_IntegrationUsesGitCredentialHelperFallbackstill passes — exercises the 401-retry path against a realhttptestbasic-auth servergitlab.baylibre.comwithout credentials configured) and confirm no prompt🤖 Generated with Claude Code
Note
Medium Risk
Changes the smart-HTTP authentication flow to retry
/info/refswith helper-supplied Basic auth on 401 and persist that auth for subsequent requests; mistakes here could cause auth failures or unintended retries. Risk is moderated by extensive new unit/integration tests and keeping explicit auth precedence unchanged.Overview
Switches HTTP auth resolution to stop eagerly invoking the git credential helper during
auth.Resolve, preventing credential leakage to public repos and avoiding interactive prompts.Adds a
CredentialHelperhook to smart-HTTP connections so that when an anonymous/info/refsrequest returns 401, git-sync performs a single credential-helper lookup, retries with Basic auth, then persists the resolved auth for the rest of the connection and signalsapprove/reject(including rejecting retry-403 as expired/invalid token). Also forcesgit credentialcalls to be non-interactive viaGIT_TERMINAL_PROMPT=0.Updates syncer wiring and tests accordingly, adding coverage for helper lookup/approve/reject behavior, non-HTTP endpoints, and ensuring explicit auth disables helper fallback.
Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.