Skip to content

fix(git): supply the stored HTTPS token to every git invocation - #35

Merged
thinkbig1979 merged 1 commit into
mainfrom
fix/git-https-token-credential-helper
Jul 31, 2026
Merged

fix(git): supply the stored HTTPS token to every git invocation#35
thinkbig1979 merged 1 commit into
mainfrom
fix/git-https-token-credential-helper

Conversation

@thinkbig1979

Copy link
Copy Markdown
Owner

Closes agent-os-qqw. Unblocks the last acceptance criterion of agent-os-36o (P0).

The bug

Capstan collects a git HTTPS token, encrypts it with STORAGE_KEY, stores it and reports hasHttpsToken: true in Settings — then never supplies it to any git operation.

gitCommand() (backend/internal/services/git.go:197 on main) exec'd plain git with no credential helper, no askpass, no authenticated URL and no environment. git_https_token was read and written only by the settings handler and referenced nowhere in internal/services.

Observed during the agent-os-36o DR drill, inside a running container, against a real clone of a private repo whose token had been stored through the API:

$ cd /opt/stacks/drstack && git pull --ff-only
fatal: could not read Username for 'https://github.com': terminal prompts disabled

The fix

An inline credential.helper shell snippet, installed with -c for the duration of a single invocation, answering only the get operation and reading the secret out of the child process environment.

The rejected alternatives, and why:

approach leak
rewrite origin to https://user:token@host persists into .git/config on disk, into that stack's backup snapshot, and into every log line or API response that prints the remote
-c http.<remote>.extraheader="Authorization: Basic ..." lands in argv, readable by any local process via /proc/<pid>/cmdline
GIT_ASKPASS script has to be written to disk and made executable

The snippet contains only variable names, so argv stays clean, and -c means nothing is written to any config file.

gitCmd() is now the single place the backend executes git — grep -rn '"git"' --include=*.go internal/ cmd/ returns one non-test hit — so the credential covers pull, fetch, status and everything else without a hand-maintained list of the subcommands that reach the network. An omission from such a list is precisely the failure mode being fixed.

Settings win over the GIT_HTTPS_TOKEN environment variable. With nothing configured the invocation is unchanged apart from GIT_TERMINAL_PROMPT=0, which turns a would-be hang into an error.

git's output is scrubbed of the token before it reaches an error string; that text travels into AppError.Details, the action log and the API response.

Tests

The tests run git's own smart-HTTP server (git-http-backend over CGI) behind Basic auth, so the pull is proved against a remote that genuinely demands credentials. Nothing leaves the machine and no real token is involved.

test asserts
TestPull_UsesStoredHTTPSToken a pull with a stored token fast-forwards to the remote's new commit
TestPull_WithoutStoredTokenStillFails guards the harness — the remote really does demand credentials
TestPull_TokenNeverPersistsToGitConfig token absent from .git/config and from the origin URL after a pull
TestGitCmd_TokenTravelsInEnvNotArgv token in the child environment, never in argv
TestGitCmd_NoCredentialWhenNoneConfigured no helper and no env var when nothing is stored
TestGitCommand_RedactsTokenFromOutput real git output carrying the token is scrubbed before it becomes an error
TestHTTPSCredentials_SettingsBeatEnvironment precedence, with the env var as fallback

Confirmed to fail for the right reason against the unfixed code, per the standing rule that a bug fix needs a test that would not also have passed before it:

DETAILS: git pull: exit status 1 (fatal: could not read Username for
'http://127.0.0.1:38441': terminal prompts disabled)

Deliberately reverting gitCommand() to its main body fails TestPull_UsesStoredHTTPSToken, TestPull_TokenNeverPersistsToGitConfig and TestGitCommand_RedactsTokenFromOutput, and passes the four that test the new helper in isolation.

Gates

go build ./... && go vet ./... && go test ./... -count=1   → exit 0
681 tests across 9 packages   (baseline 674/9, +7 new)
go vet -tags=integration ./internal/integrationtest/       → exit 0

Frontend untouched.

Not in scope

Two adjacent problems surfaced while reading this code, both filed separately rather than folded in:

  • per-directory git credentials (UpdateDirectoryCredentials) are written but never read back — GetDirectory blanks the token on the way out, so no caller can reach it;
  • UpsertDirectory is INSERT OR REPLACE over a models.Directory built by the scanner with empty credential fields, so every scan wipes any per-directory credential that was stored.

Only the global Settings token is wired up here, which is what agent-os-qqw's acceptance criterion covers.

Capstan encrypted the git HTTPS token, stored it and reported
hasHttpsToken=true, but never handed it to a git process. Every pull of a
private HTTPS repository died with:

    fatal: could not read Username for 'https://github.com'

gitCommand() exec'd plain git with no credential helper, no askpass, no
authenticated URL and no environment (git.go:197).

The credential now reaches git through an inline credential.helper snippet
installed with -c for the duration of one invocation, reading the secret out
of the child process environment. That keeps it out of argv (readable via
/proc/<pid>/cmdline), out of .git/config on disk — and therefore out of the
stack's backup snapshot — and out of anything that prints the remote.
Rewriting origin to https://user:token@host was rejected for exactly those
reasons.

gitCmd() is now the single place the backend executes git, so the credential
applies to pull, fetch, status and every other subcommand without a
hand-maintained list of the ones that reach the network. Settings win over
the GIT_HTTPS_TOKEN environment variable; with nothing configured the
invocation is unchanged apart from GIT_TERMINAL_PROMPT=0, which turns a
would-be hang into an error.

git output is scrubbed of the token before it reaches an error string, since
that text travels into AppError.Details, the action log and the API response.

Tests run git's own smart-HTTP server (git-http-backend over CGI) behind
Basic auth, so the pull is proved against a remote that genuinely demands
credentials. Verified to fail for the right reason against the unfixed code:
"fatal: could not read Username for 'http://127.0.0.1:38441': terminal
prompts disabled".

agent-os-qqw
@thinkbig1979
thinkbig1979 merged commit be56af9 into main Jul 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant