Merge pull request #1721 from entireio/fix/1523-checkpoint-push-batchmode-ssh · Entire
Merge pull request #1721 from entireio/fix/1523-checkpoint-push-batchmode-ssh
62e9b19→main·suhaanthayyil·2d ago·5 files·+455 added/-1 removed
fix(checkpoint): fail fast on interactive SSH prompt during pre-push
Changes
cmd/entire/cli
checkpoint/remote
Mgit.go+169
Mgit_test.go+185
strategy
Mmanual_commit_push.go+30/-1
Mpush_common.go+37
Mpush_common_test.go+34
7 unmodified lines
var sshTokenWarningOnce sync.Once //nolint:gochecknoglobals // intentional per-process gate
// nonInteractiveSSHKey marks a context whose checkpoint git subprocesses must
// never block on an interactive SSH prompt (e.g. a key passphrase when no
// ssh-agent is running).
type nonInteractiveSSHKey struct{}
// WithNonInteractiveSSH marks ctx so every checkpoint git command spawned under
// it runs SSH with BatchMode=yes, failing fast instead of hanging on an
// interactive prompt. Set this at best-effort, non-interactive entry points such
// as the git pre-push hook: a blocked passphrase prompt there would hang the
// user's own `git push` until the checkpoint push budget kills it, with no way
// to type the passphrase. Foreground commands (resume, explain) leave it unset
// so they can still prompt.
//
// BatchMode tradeoffs (issue #1523):
// - Passphrase-protected keys with no ssh-agent: fail fast (desired).
// - Touch-only security keys (sk-, user-presence only): still work — touch is
// not a terminal passphrase read.
// - PIN-protected FIDO2 keys (verify-required): PIN entry goes through ssh's
// passphrase reader, so BatchMode suppresses it and the push fails. Load the
// key into ssh-agent beforehand, or set an explicit BatchMode=no via
// GIT_SSH_COMMAND / core.sshCommand (respected; we do not override it).
func WithNonInteractiveSSH(ctx context.Context) context.Context {
return context.WithValue(ctx, nonInteractiveSSHKey{}, true)
}
// IsNonInteractiveSSH reports whether ctx was marked with WithNonInteractiveSSH.
func IsNonInteractiveSSH(ctx context.Context) bool {
return nonInteractiveSSHFromContext(ctx)
}
func nonInteractiveSSHFromContext(ctx context.Context) bool {
v, ok := ctx.Value(nonInteractiveSSHKey{}).(bool)
return ok && v
}
// LooksLikeSSHAuthFailure reports whether errText looks like an SSH
// authentication failure (passphrase/PIN unavailable under BatchMode, missing
// agent identity, publickey rejection, etc.). Used to print an actionable
// ssh-agent hint from the pre-push checkpoint path.
func LooksLikeSSHAuthFailure(errText string) bool {
if errText == "" {
return false
}
lower := strings.ToLower(errText)
needles := []string{
"permission denied (publickey)",
"permission denied (keyboard-interactive",
"permission denied (password)",
"too many authentication failures",
"no more authentication methods to try",
}
for _, n := range needles {
if strings.Contains(lower, n) {
return true
}
}
if strings.Contains(lower, "permission denied") && strings.Contains(lower, "publickey") {
return true
}
return false
}
// batchModeOptionRe matches an explicit BatchMode ssh option (e.g.
// "-o BatchMode=yes" or "BatchMode=no"), case-insensitively. Anchored with \b
// so it doesn't false-positive on unrelated text that merely contains
// "BatchMode" as a substring of a longer token.
var batchModeOptionRe = regexp.MustCompile(`(?i)\bBatchMode\s*=\s*\S+`)
// hasExplicitBatchMode reports whether sshCmd already sets a BatchMode option,
// with any value. A user-supplied BatchMode=no is a deliberate choice and must
// be respected, not silently overridden to yes.
func hasExplicitBatchMode(sshCmd string) bool {
return batchModeOptionRe.MatchString(sshCmd)
}
// envLookup returns the value of the last occurrence of key in env (matching
// exec.Cmd's last-wins semantics for duplicate entries) and whether it was
// found.
func envLookup(env []string, key string) (string, bool) {
prefix := key + "="
for i := len(env) - 1; i >= 0; i-- {
if v, ok := strings.CutPrefix(env[i], prefix); ok {
return v, true
}
}
return "", false
}
// gitConfigSSHCommand looks up core.sshCommand via `git config`, run with env
// so the lookup honors any HOME/GIT_CONFIG_* overrides present in env (e.g. in
// tests). Returns "" if unset or the lookup fails.
func gitConfigSSHCommand(ctx context.Context, env []string) string {
cmd := exec.CommandContext(ctx, "git", "config", "--get", "core.sshCommand")
cmd.Env = env
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// effectiveSSHCommand resolves the ssh invocation git itself would use, in
// git's own precedence order: the GIT_SSH_COMMAND environment variable, then
// the core.sshCommand git config value, then the GIT_SSH environment
// variable, falling back to plain "ssh" when none are set.
func effectiveSSHCommand(ctx context.Context, env []string) string {
if v, ok := envLookup(env, "GIT_SSH_COMMAND"); ok {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
if v := gitConfigSSHCommand(ctx, env); v != "" {
return v
}
if v, ok := envLookup(env, "GIT_SSH"); ok {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return "ssh"
}
// withBatchModeSSH returns env with GIT_SSH_COMMAND set so ssh runs with
// BatchMode=yes. The base ssh invocation is resolved via effectiveSSHCommand
// (env GIT_SSH_COMMAND > core.sshCommand > GIT_SSH > plain "ssh") so a custom
// ssh command configured via core.sshCommand isn't silently discarded. The
// flag is only appended when BatchMode isn't already explicitly set — an
// existing BatchMode=no is a deliberate user choice and is left untouched —
// so the result is idempotent.
func withBatchModeSSH(ctx context.Context, env []string) []string {
const key = "GIT_SSH_COMMAND="
base := effectiveSSHCommand(ctx, env)
out := make([]string, 0, len(env)+1)
for _, e := range env {
if strings.HasPrefix(e, key) {
continue
}
out = append(out, e)
}
if !hasExplicitBatchMode(base) {
base += " -o BatchMode=yes"
}
return append(out, key+base)
}
// applyNonInteractiveSSH sets BatchMode SSH on cmd when ctx is marked
// non-interactive (see WithNonInteractiveSSH). No-op otherwise, so foreground
// commands keep their interactive prompt behavior.
func applyNonInteractiveSSH(ctx context.Context, cmd *exec.Cmd) {
if !nonInteractiveSSHFromContext(ctx) {
return
}
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = withBatchModeSSH(ctx, cmd.Env)
}
// FetchOptions configures a git fetch operation.
type FetchOptions struct {
Remote string // remote name or URL (required)
// Other fields omitted for brevity
}
Test Cases and Functions
assert.True(t, gitConfigBool(context.Background(), repoDir, "remote."+url+".skipFetchAll"),
"stamp must land even though the parent context is cancelled")
// isolatedSSHEnv returns a hermetic env slice for withBatchModeSSH tests: a
// fresh HOME with no .gitconfig and system/global config lookups disabled, so
// the effective ssh command resolution isn't polluted by the host machine's
// real git config. extra entries (e.g. GIT_SSH_COMMAND, GIT_SSH, or a
// GIT_CONFIG_GLOBAL pointing at a fixture config) are appended on top.
func isolatedSSHEnv(t *testing.T, extra ...string) []string {
t.Helper()
env := []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GIT_CONFIG_NOSYSTEM=1",
}
return append(env, extra...)
}
Summary
- This document covers key changes and implementation details concerning handling SSH authentication failure and user prompts in git operations as batch non-interactive tasks.