fix(checkpoint): fail fast on interactive SSH prompt during pre-push · Entire

fix(checkpoint): fail fast on interactive SSH prompt during pre-push

db05861→main·

suhaanthayyil·2d ago·3 files·+161 added/-0 removed

Checkpoint sync runs inside the user's git push pre-push hook. When the push remote uses SSH with a passphrase-protected key and no ssh-agent is running, git's ssh blocks on a passphrase prompt that can't be answered in the hook, hanging the user's push until the checkpoint push budget expires.

Mark the pre-push context non-interactive and have every checkpoint git subprocess spawned under it run with GIT_SSH_COMMAND set to ssh -o BatchMode=yes, so ssh fails fast instead of hanging. This covers the whole pre-push flow (metadata fetch, policy sync, checkpoint push and its recovery fetch), not just the push, since any of them can trigger the same prompt.

An existing GIT_SSH_COMMAND is preserved and extended rather than replaced, the flag is only added when absent (idempotent), and foreground commands (resume, explain) leave the context interactive so they can still prompt. Checkpoint sync is best-effort, so failing fast is the correct behavior.

Closes #1523

Co-authored-by: Cursor cursoragent@cursor.com

Changes

3

32 unmodified lines

33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
318 unmodified lines

414
415
416
417
418
419
420
421
422
423
424

32 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.
func WithNonInteractiveSSH(ctx context.Context) context.Context {
    return context.WithValue(ctx, nonInteractiveSSHKey{}, true)
}

func nonInteractiveSSHFromContext(ctx context.Context) bool {
    v, ok := ctx.Value(nonInteractiveSSHKey{}).(bool)
    return ok && v
}

// withBatchModeSSH returns env with GIT_SSH_COMMAND set so ssh runs with
// BatchMode=yes. An existing GIT_SSH_COMMAND (from the environment or a caller's
// custom ssh wrapper) is preserved and extended rather than replaced, and the
// flag is only appended when absent so the result is idempotent.
func withBatchModeSSH(env []string) []string {
    const key = "GIT_SSH_COMMAND="
    base := "ssh"
    out := make([]string, 0, len(env)+1)
    for _, e := range env {
        if v, ok := strings.CutPrefix(e, key); ok {
            if trimmed := strings.TrimSpace(v); trimmed != "" {
                base = trimmed
            }
            continue
        }
        out = append(out, e)
    }
    if !strings.Contains(base, "BatchMode") {
        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(cmd.Env)
}

// FetchOptions configures a git fetch operation.
type FetchOptions struct {
    Remote   string   // remote name or URL (required)
318 unmodified lines

c := exec.CommandContext(ctx, "git", finalArgs...)
        c.Stdin = nil // Disconnect stdin to prevent hanging in hook context
        terminateOnCancel(c)
        // Fail fast on interactive SSH prompts (e.g. a key passphrase with no
        // ssh-agent) when the caller marked ctx non-interactive. HTTPS token
        // auth rebuilds cmd.Env below (SSH is not used there), so this only
takes effect on the SSH/no-token paths that actually run ssh.
        applyNonInteractiveSSH(ctx, c)
        return c
    }
``

Mcmd/entire/cli/checkpoint/remote/git.go+62

1088 unmodified lines

1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175

1088 unmodified lines

assert.True(t, gitConfigBool(context.Background(), repoDir, "remote."+url+".skipFetchAll"), "stamp must land even though the parent context is cancelled") }

func TestWithBatchModeSSH(t *testing.T) { t.Parallel()

tests := []struct { name string in []string want string }{ { name: "no existing GIT_SSH_COMMAND defaults to ssh", in: []string{"PATH=/usr/bin"}, want: "ssh -o BatchMode=yes", }, { name: "preserves and extends a custom ssh command", in: []string{"GIT_SSH_COMMAND=ssh -i /home/me/.ssh/id"}, want: "ssh -i /home/me/.ssh/id -o BatchMode=yes", }, { name: "idempotent when BatchMode already present", in: []string{"GIT_SSH_COMMAND=ssh -o BatchMode=yes"}, want: "ssh -o BatchMode=yes", }, { name: "blank GIT_SSH_COMMAND falls back to ssh", in: []string{"GIT_SSH_COMMAND= "}, want: "ssh -o BatchMode=yes", }, }

for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() out := withBatchModeSSH(tt.in) got, ok := envToMap(out)["GIT_SSH_COMMAND"] assert.True(t, ok, "GIT_SSH_COMMAND should be set") assert.Equal(t, tt.want, got) }) } }

func TestWithBatchModeSSH_PreservesOtherVarsWithoutDuplicating(t *testing.T) { t.Parallel()

out := withBatchModeSSH([]string{"PATH=/usr/bin", "HOME=/home/me", "GIT_SSH_COMMAND=ssh"})

count := 0 for _, e := range out { if strings.HasPrefix(e, "GIT_SSH_COMMAND=") { count++ } } assert.Equal(t, 1, count, "should not duplicate GIT_SSH_COMMAND")

m := envToMap(out) assert.Equal(t, "/usr/bin", m["PATH"]) assert.Equal(t, "/home/me", m["HOME"]) assert.Equal(t, "ssh -o BatchMode=yes", m["GIT_SSH_COMMAND"]) }

// TestNewCommand_NonInteractiveSSH verifies that a checkpoint git command built // under a non-interactive context carries GIT_SSH_COMMAND with BatchMode=yes, so // an SSH push cannot hang on a passphrase prompt (issue #1523). Without the // marker, the command is left untouched so foreground commands keep interactive // prompting. func TestNewCommand_NonInteractiveSSH(t *testing.T) { // Not parallel: manipulates the checkpoint token env var. t.Setenv(CheckpointTokenEnvVar, "") // ensure SSH/no-token path

t.Run("marked context adds BatchMode", func(t *testing.T) { ctx := WithNonInteractiveSSH(context.Background()) cmd := newCommand(ctx, "push", "--no-verify", "origin", "entire/checkpoints/v1") sshCmd, ok := envToMap(cmd.Env)["GIT_SSH_COMMAND"] assert.True(t, ok, "non-interactive command must set GIT_SSH_COMMAND") assert.Contains(t, sshCmd, "BatchMode=yes") })

t.Run("unmarked context leaves env untouched", func(t *testing.T) { cmd := newCommand(context.Background(), "push", "--no-verify", "origin", "entire/checkpoints/v1") // No token and no marker: newCommand should not populate cmd.Env, so no // BatchMode is injected and the process inherits the parent environment. assert.Nil(t, cmd.Env, "unmarked command should not set a custom env") }) }