fix(checkpoint): hint ssh-agent on BatchMode auth failure · Entire
fix(checkpoint): hint ssh-agent on BatchMode auth failure
e3e5b7b→main·
Pre-push checkpoint pushes already fail fast under BatchMode=yes; when the failure is SSH auth-shaped, print an actionable ssh-agent hint instead of only a Permission denied warning. Document the touch-only vs PIN-protected security-key tradeoff and the BatchMode=no escape hatch.
Co-authored-by: Cursor cursoragent@cursor.com
Changes
4
cmd/entire/cli
checkpoint/remote
Mgit.go+45
Mgit_test.go+28
strategy
Mpush_common.go+30
Mpush_common_test.go+22
45 unmodified lines
// 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",
"could not read from remote repository",
"enter passphrase for key", // should not appear under BatchMode, but keep
"error reading ssh protocol banner", // sometimes accompanies aborted auth
}
for _, n := range needles {
if strings.Contains(lower, n) {
return true
}
}
// Generic publickey denial without the parenthetical form.
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
1243 unmodified lines
assert.Nil(t, cmd.Env, "unmarked command should not set a custom env")
})
func TestLooksLikeSSHAuthFailure(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want bool
}{
{"git push: Permission denied (publickey).", true},
{"Permission denied (publickey,password).", true},
{"ERROR: Permission denied (publickey).
\nfatal: Could not read from remote repository.", true},
{"fatal: Could not read from remote repository.", true},
{"non-fast-forward", false},
{"Connection timed out", false},
{"", false},
}
for _, tt := range cases {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, LooksLikeSSHAuthFailure(tt.in))
})
}
}
func TestIsNonInteractiveSSH(t *testing.T) {
t.Parallel()
assert.False(t, IsNonInteractiveSSH(context.Background()))
assert.True(t, IsNonInteractiveSSH(WithNonInteractiveSSH(context.Background())))
}
Mcmd/entire/cli/checkpoint/remote/git.go+45
177 unmodified lines
// Non-interactive SSH (pre-push BatchMode): auth failures cannot be fixed by
// fetch+rebase, and retrying would just reprint the same opaque error.
// Surface an actionable ssh-agent hint and skip recovery (issue #1523).
if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(err.Error()) {
fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't push %s: %v\n", refLabel, err)
printNonInteractiveSSHAuthHint()
printCheckpointRemoteHint(target)
return nil
}
// Push failed - likely non-fast-forward. Try to fetch and rebase.
// Spanned (with the network fetch as a child) so the trace distinguishes
// "the raw push is slow" from "we keep hitting contention and re-syncing".
if syncErr != nil {
stop("")
fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", refLabel, syncErr)
if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(syncErr.Error()) {
printNonInteractiveSSHAuthHint()
}
printCheckpointRemoteHint(target)
return nil // Don't fail the main push
}
3 unmodified lines
// Reset the once for this test process isolation: reassign the sync.Once.
sshAuthHintOnce = sync.Once{}
var buf bytes.Buffer
old := os.Stderr
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stderr = w
printNonInteractiveSSHAuthHint()
printNonInteractiveSSHAuthHint() // second call must be a no-op
require.NoError(t, w.Close())
os.Stderr = old
_, copyErr := io.Copy(&buf, r)
require.NoError(t, copyErr)
out := buf.String()
assert.Contains(t, out, "ssh-add")
assert.Contains(t, out, "Checkpoint push skipped")
assert.Equal(t, 1, strings.Count(out, "Checkpoint push skipped"), "hint must print once")
}
Mcmd/entire/cli/strategy/push\_common.go+30
3 unmodified lines