Git credentials
Helmry does not store your git credentials, and will not ask for them. That is a decision, not a gap. This page says why, what the app does instead, and the one-time setup each platform needs.
Read this before debugging a failed push, and before configuring anything on Windows + WSL. The recipe that looks obvious there — pointing a distro’s git at the Windows Git Credential Manager — is measured broken on this machine and turns every push into a 60-second hang. It is written up under One-time setup; the short version is use an ssh key inside the distro. Two people (and one agent) have now rediscovered that the hard way because nothing linked here.
Why not
Three reasons, and the third is the decisive one.
- A git credential is wider than Helmry’s trust boundary. An account here is already a key to the
whole machine (see
CLAUDE.md), but a personal access token is a key to repositories, organisations and CI beyond it. Silently widening the first into the second is not ours to do. - A second store drifts. A rotated token, a revoked grant, a scope that was never requested — all of it would diverge from the one the user’s own terminal already uses.
- Delegating through git’s config keeps the secret out of the agent’s environment. With a
credential.helperconfigured,git pushfrom an agent’s ownBashworks and the token never appears in that process’s environment: git talks to the helper itself. Handing the credential to the child throughGIT_ASKPASSwould put it oneenvaway from a transcript — the same reasoning that keeps Atlassian’s OAuth grant out of the agent’s MCP config (docs/tasks.md§4).
Every serious GUI git client works this way (VS Code, Fork); the ones that keep their own token store are the ones users fight with over re-authentication.
What Helmry does instead
-
Never blocks on a prompt — in both halves. Every git subprocess runs with
GIT_TERMINAL_PROMPT=0andGCM_INTERACTIVE=never(server/src/workspace/git-env.ts), and so does every agent (agent/claude-cli/claude-cli.ts). The first stops git asking; without it apushon a machine with no helper spent its whole 60-second timeout waiting for a username nobody could type, then reported the timeout rather than the cause. The second stops the helper git spawned, which the first cannot touch — and that half was missing.Measured 2026-08-16, Windows git 2.52.0 + Git Credential Manager 2.6.1, the default
credential.helper = manager, nothing cached for the host, asked from a child with no tty:outcome GIT_TERMINAL_PROMPT=0alonenever returns, and leaves a 93.6 MB git-credential-manager.exeorphaned per attempt+ GCM_INTERACTIVE=never206 ms, exit 128, terminal prompts disabledThis retires a claim that used to be on this page: that a helper’s GUI window is “the sanctioned way a first credential is obtained”. It does not appear for a process with no terminal — not even one running inside the user’s own interactive desktop session. It hangs instead. So on Windows there is no such thing as an agent obtaining a first credential, and the honest instruction is the one under One-time setup: authenticate once yourself, in your own terminal.
-
Kills the helper, not just git. A timeout signals the whole process group / tree (
server/src/workspace/proc-kill.ts), becausechild.kill()andexecFile’s owntimeoutreach the direct child only — which is how the 93.6 MB orphans above survived.GCM_INTERACTIVE=neverstops that helper hanging in the first place; this bounds a helper we have not met. -
Asks git, rather than guessing.
git credential fillwith prompting disabled answers “is there a credential for this host” without a network call, and without the probe being able to raise a dialog (credential.interactive=false). That answer is what the git window’s remote line states, and the git window’s Check access button re-asks it on demand and reports how long it took. -
Distinguishes “no credential” from “the helper is not answering.” Those used to be one answer (
unknown), rendered as “credentials unknown until you push” — an invitation to do the one thing that cannot resolve it. A helper that stalls does not fail, it hangs, and it will hang identically every time until it is replaced. It is now its own state (stalled) with its own message, andhelmry doctorreports it separately from a missing credential. -
Refuses before the network, not by the network. A push or fetch on a machine with no credential (or a stalled helper) used to spend its full 60-second budget and then report the timeout rather than the cause. Both facts are decidable locally in about five seconds, so
preflightRemotenow decides them first and the operation fails immediately with a name. -
For ssh, checks the endpoint instead — and exactly one fact about it. Keys stay the ssh agent’s business, so ssh is never gated on credentials. What it IS gated on (
workspace/ssh-preflight.ts) is the one impossibility that is cheaply decidable: the endpoint accepts a TCP connection and never sends its SSH banner — a real SSH server announces itself in its first packet. Measured 2026-08-21 on this machine: a poisoned Windows DNS cache resolved bitbucket.org to10.0.0.1, something there accepted:22and stayed mute, and every push hung ~135 s before reporting a timeout; the probe names it in 4 s, with the resolved address in the message (a private address for a remote host is called out as the DNS-interception signature it is; the cure wasClear-DnsClientCache). Everything uncertain passes untouched: the probe asksssh -Gfor the effective endpoint (so a config-levelHostName/Portrewrite is probed where ssh would really go) and skips itself entirely underProxyCommand/ProxyJump,GIT_SSH/GIT_SSH_COMMANDorcore.sshCommand, whensshis missing, when the name does not resolve, and for endpoints that are unreachable or refuse/close the connection — git fails fast there with wordsclassifyGitFailurealready names. Addresses are probed in parallel on one deadline, because the measured incident’s shape was unreachable IPv6 beside a mute IPv4 and a sequential probe would spend its whole budget on the dead family. -
Decides where a push goes — never
push.default. A baregit pushleaves the destination ref to the machine’s config, and a branch cut fromorigin/masterinheritsorigin/masteras its upstream (branch.autoSetupMergeistrueby default, and Helmry’s ownworktree add -b … <base>andswitch -c … <base>were two of the places doing the cutting). Measured on git 2.50.1, withDYNQRA-6563trackingorigin/master:push.defaultgit pushunset ( simple, the default since git 2.0)refuses: The upstream branch … does not matchupstream/trackingDYNQRA-6563 -> master— the feature branch is published as masterThe second row is a silent, outward-facing write to the branch everyone else builds on, from a button whose confirmation named a repo, a branch and a remote URL but never a ref. So the destination is computed by
server/src/workspace/push-plan.ts, spelled as an explicit refspec (<branch>:refs/heads/<branch>), and shown in the dialog before the user agrees to it. A push from Helmry always writes the branch’s own name: when the upstream names another branch it is reported (retargets), not followed, and the push re-points the branch as it goes. Branches are also no longer born mis-tracked — both creation sites pass--no-trackunless the new branch has the start point’s own name (git-switch.inheritsTracking). Landing on the repository’s default branch is not refused (a person onmasterpushingorigin/masteris ordinary) but it is named:targetProtectedmakes the dialog a red one that Enter will not confirm.Deliberately not done: forcing
push.defaultinto the agents’ own environment viaGIT_CONFIG_KEY_*. Those override the user’s repo and global config for every command an agent runs, and for somebody onpush.default = currentthat turns a workinggit pushinto an error. The agent’s own pushes are a separate question from this button’s, and one that needs its own measurement first. -
Names the failure.
could not read Username for 'https://github.com'becomes “No git credentials on this machine” plus what to do; the raw text stays available. SeeclassifyGitFailurefor the full list of named causes. -
Answers per environment. A Windows hub and a WSL satellite have separate credential state, so the question is asked on the server that would run the push — not on whichever one the browser is talking to. This is the same rule spend follows: the fact belongs to the machine that does the work.
-
Reports it up front.
helmry doctorprints a line per repository:git auth · AgentHub — github.com · https · NO credentials here.
One-time setup
Pick the one for the machine (or the distro) that runs the agents.
| Where | What to run |
|---|---|
| Windows | Push once from your own terminal. Git for Windows ships Git Credential Manager, which handles GitHub/GitLab/Bitbucket OAuth in a window — but only where a person is present. It caches the result in Windows Credential Manager, and every agent works non-interactively from then on. An agent cannot do this first step for you (see the measurement above). |
| WSL (repos inside a distro) | Use an ssh key inside the distro (ssh-keygen -t ed25519, then add the public key to the hosting account — the git window links to that page). The obvious alternative, pointing the distro’s git at the Windows helper, is measured broken here — see the warning below before trying it. |
| macOS | git config --global credential.helper osxkeychain (ships with git). |
| Linux desktop | git config --global credential.helper libsecret (needs git-credential-libsecret and a running secret service). |
| Headless Linux | Either git config --global credential.helper store — a plaintext file at ~/.git-credentials, mode 600 — or an ssh remote with a deploy key. Choose deliberately; Helmry will not write that file for you. |
Then confirm — and do confirm, because a helper that hangs is worse than no helper at all:
printf 'protocol=https\nhost=github.com\n\n' | GIT_TERMINAL_PROMPT=0 git -c credential.interactive=false credential fill
It must answer in under a second, either with username=/password= lines or with a non-zero exit.
GIT_TERMINAL_PROMPT=0 cannot stop a helper from blocking, so one that neither answers nor exits turns
every push into a 60-second timeout with nothing in the log.
The WSL → Git Credential Manager trap
Pointing a distro’s git at
/mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exedoes not give the distro Windows’ credentials. Measured on this machine with thestorehelper disabled so GCM is the only one asked: without the flag it hangs (killed at 25s); withGCM_INTERACTIVE=neverit returns in 213 ms and declines. Helmry sets that flag now, so the bridge is no longer a hang — it is a fast, useless failure, reported asmissingrather thanstalled.This corrects what this page said before, which was that the flag did not help and the helper never returned “even with
GCM_INTERACTIVE=never”. The likely reason that measurement said so: a variable set inside a distro does not reach a Windows process unless it is named inWSLENV, so the flag was never seen by GCM.nonInteractiveCredentialEnvappends it toWSLENVfor exactly this reason, andgit-env.test.tspins that.The recommendation is unchanged, because a fast failure is still a failure: use an ssh key inside the distro, or the plaintext
storehelper. What has changed is the cost of getting it wrong — a misconfigured bridge now wastes 213 ms instead of every push’s full timeout.
After a working setup: helmry doctor shows credentials ready for that repository, and the git
window’s remote line agrees — or press Check access in the git window, which re-runs the probe and
reports the answer and how long it took. A helper that says yes after several seconds is the one that
will make every push feel broken.
ssh instead
Nothing to configure in Helmry: keys belong to the OS and its agent. Two things to know — a server
launched from a GUI (the desktop app, a shortcut) may not inherit SSH_AUTH_SOCK, which doctor’s
git environment line reports; and an ssh remote answers credentials … decided at push time, because
the credential helper knows nothing about keys and claiming otherwise would send you to fix the wrong
thing.
Which hosting services get deep links
origin’s host decides whether Helmry can offer an “open a pull request” link after a push, and the
answer is deliberately narrow: github.com, gitlab.com and bitbucket.org only (shared/git-forge.ts).
Everything else — self-hosted GitLab, Gitea, a bare path on a server — gets no link, because a
self-hosted host is indistinguishable from any other by URL and a guessed /-/merge_requests/new is a
404 with your branch name in it. Authentication, the credential probe and push/fetch/pull are
host-agnostic and work everywhere; only the browser link is withheld.
No forge API is ever called and no forge token is ever held. These are URLs opened in a browser you are already signed into — which is what keeps three services’ worth of convenience free of three services’ worth of OAuth obligations.
What is deliberately not built
Helmry as a credential helper — storing a token in SecretStore and handing it to git through a
helper process, or minting one ourselves through a GitHub/GitLab/Bitbucket OAuth app. Re-examined when
the WSL-GCM trap above turned out to hit this product’s primary platform, and rejected again — but one
of the original reasons does not survive the re-examination and should not be repeated:
“it makes the token reachable by any process Helmry launches, including an agent with a shell”— this is true of every helper. An agent can rungit credential fillitself and print the result, whether the helper is ours,store, or the OS keychain. Our own store would not create that exposure and does not close it; the argument was overweighted.- The reason that does hold: minting tokens makes us the issuing party for repo-scoped credentials across three vendors, each with its own developer terms and its own revocation surface — to solve a problem an ssh key solves with no secret on our side at all. Plus the original drift objection (a second store diverges from the one the user’s terminal uses), which stands unchanged.
Revisit trigger: a user on a headless box where ssh is blocked by policy and no OS helper is available. Not before, and not on a repeat of the same question without that new fact.
A GIT_ASKPASS shim that explains itself — pointing an agent’s git at a script which supplies no
credential and instead prints “Helmry: no git credential is configured for this environment; run
helmry doctor”. It would hold the opposite of a secret, and it fires exactly in the failure case
(askpass is consulted only after every helper has declined). Spiked and it works on POSIX — git
prints the script’s stderr first, ahead of its own message:
Helmry: no git credential is configured for this environment. …
error: unable to read askpass response from '/tmp/askpass.sh'
fatal: could not read Username for 'https://github.com': terminal prompts disabled
Not shipped, for one reason: GIT_ASKPASS takes a bare command path with no arguments, and how Git
for Windows executes a shebang script through it is not something this spike could establish from
Linux. Shipping a launch mechanism whose Windows behaviour is unverified is the mistake that cost a
release once already (spawnHiddenDetached, CLAUDE.md). The value it adds over what now exists —
GIT_TERMINAL_PROMPT=0 giving a searchable message, plus this page being linked from CLAUDE.md and
the README — is one sentence. Revisit trigger: a Windows machine to verify it on, or a report of
an agent looping on credential failures despite the current message.