Merge branch 'main' into feat/checkpoint-list-json · Entire
Log in
Merge branch 'main' into feat/checkpoint-list-json
c987526→main·
suhaanthayyil·2d ago·25 files·+1,216 added/-131 removed
Changes
25
cmd/entire/cli
checkpoint
Mcheckpoint_test.go+95
Mephemeral.go+34/-5
remote
Mgit.go+169
Mgit_test.go+185
Mcheckpoint_policy_warning.go+1/-1
Mcheckpoint_policy_warning_test.go+4
execx
Aspawn_detached.go+49
Mlabs_test.go+23/-2
Mlifecycle_test.go+241
paths
Mpaths.go+54/-2
Mpaths_test.go+59
Mrewind_test.go+9
Mroot.go+1
Mstate.go+2/-3
strategy
Mcommon.go+1/-1
Mmanual_commit_push.go+30/-1
Mpush_common.go+37
Mpush_common_test.go+34
telemetry
Mdetached.go+8
Ddetached_other.go-11
Ddetached_unix.go-47
Ddetached_windows.go-51
Mtrail_context_cache.go+177/-4
Mgo.mod+1/-1
Mgo.sum+2/-2
13 unmodified lines
14
15
16
17
18
19
20
21
83 unmodified lines
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
13 unmodified lines
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
_ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // register claude-code so its .claude protected dir is discoverable
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/testutil"
83 unmodified lines
}
}
// fakePluginAgent is a minimal agent stub used to prove that protected dirs
// and files reported by an external-plugin-style agent (via the AllProtectedDirs
// / AllProtectedFiles union) are honored by the first-checkpoint path, not just
// the built-in claude-code .claude dir.
type fakePluginAgent struct{}
var (
_ agent.Agent = (*fakePluginAgent)(nil)
_ agent.ProtectedFilesProvider = (*fakePluginAgent)(nil)
)
func (fakePluginAgent) Name() types.AgentName { return "terminalhire-plugin" }
func (fakePluginAgent) Type() types.AgentType { return "TerminalHire" }
func (fakePluginAgent) Description() string { return "fake external plugin for tests" }
func (fakePluginAgent) IsPreview() bool { return true }
func (fakePluginAgent) ProtectedDirs() []string { return []string{".terminalhire"} }
func (fakePluginAgent) ProtectedFiles() []string { return []string{".terminalhirerc"} }
func (fakePluginAgent) GetSessionID(*agent.HookInput) string { return "" }
func (fakePluginAgent) DetectPresence(context.Context) (bool, error) { return false, nil }
func (fakePluginAgent) ReadTranscript(string) ([]byte, error) { return nil, nil }
func (fakePluginAgent) ChunkTranscript(_ context.Context, c []byte, _ int) ([][]byte, error) {
return [][]byte{c}, nil
}
func (fakePluginAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
var out []byte
for _, c := range chunks {
out = append(out, c...)
}
return out, nil
}
func (fakePluginAgent) GetSessionDir(string) (string, error) { return "", nil }
func (fakePluginAgent) ResolveSessionFile(dir, sid string) string { return dir + "/" + sid }
func (fakePluginAgent) ReadSession(*agent.HookInput) (*agent.AgentSession, error) { return nil, nil } //nolint:nilnil // test stub
func (fakePluginAgent) WriteSession(context.Context, *agent.AgentSession) error { return nil }
func (fakePluginAgent) FormatResumeCommand(string) string { return "" }
// TestCollectChangedFiles_ExcludesProtectedDirs verifies that the
// first-checkpoint path keeps agent-protected dirs (e.g. .claude) and the
// .entire infrastructure dir out of the checkpoint snapshot, while ordinary
// untracked files are still captured. Regression for protected-dir content
// leaking into the shadow tree on session start.
func TestCollectChangedFiles_ExcludesProtectedDirs(t *testing.T) {
t.Parallel()
// Register an external-plugin-style agent so its protected dir/file join the
// AllProtectedDirs/AllProtectedFiles union alongside the built-in .claude.
// Registration is additive and concurrency-safe; no test asserts the exact set.
agent.Register("terminalhire-plugin", func() agent.Agent { return fakePluginAgent{} })
tempDir := t.TempDir()
// Resolve symlinks so the repo root matches git's resolved path.
// On macOS, t.TempDir() returns /var/... but git resolves to /private/var/...
tempDir, err := filepath.EvalSymlinks(tempDir)
require.NoError(t, err)
testutil.InitRepo(t, tempDir)
testutil.WriteFile(t, tempDir, "base.txt", "base")
testutil.GitAdd(t, tempDir, "base.txt")
testutil.GitCommit(t, tempDir, "init")
// Disable any global core.excludesFile so a developer/CI-runner gitignore
// convention (e.g. one that ignores .claude) can't mask the leak. The fix
// must exclude protected dirs on its own, independent of gitignore state.
cfgCmd := exec.CommandContext(context.Background(), "git", "config", "core.excludesFile", os.DevNull)
cfgCmd.Dir = tempDir
require.NoError(t, cfgCmd.Run())
// Planted untracked, non-gitignored files.
testutil.WriteFile(t, tempDir, ".claude/marker.txt", "MARKER-secret") // built-in agent-protected dir
testutil.WriteFile(t, tempDir, ".terminalhire/profile.json", "MARKER-plugin") // plugin-protected dir
testutil.WriteFile(t, tempDir, ".terminalhirerc", "MARKER-plugin-file") // plugin-protected file
testutil.WriteFile(t, tempDir, ".entire/state.json", "{}") // infrastructure
testutil.WriteFile(t, tempDir, "src/keep.txt", "user work") // ordinary
repo, err := git.PlainOpen(tempDir)
require.NoError(t, err)
result, err := collectChangedFiles(context.Background(), repo)
require.NoError(t, err)
require.NotContains(t, result.Changed, ".claude/marker.txt",
"built-in agent protected dir content must not be captured into the checkpoint")
require.NotContains(t, result.Changed, ".terminalhire/profile.json",
"external-plugin protected dir content must not be captured into the checkpoint")
require.NotContains(t, result.Changed, ".terminalhirerc",
"external-plugin protected file must not be captured into the checkpoint")
require.NotContains(t, result.Changed, ".entire/state.json",
"infrastructure dir must not be captured into the checkpoint")
require.Contains(t, result.Changed, "src/keep.txt",
"ordinary untracked files must still be captured")
}
// TestWriteCommitted_AgentField verifies that the Agent field is written
// to both metadata.json and the commit message trailer.
func TestWriteCommitted_AgentField(t *testing.T) {
Mcmd/entire/cli/checkpoint/checkpoint_test.go+95
1194 unmodified lines
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
45 unmodified lines
1275
1276
1277
1249
1278
1279
1280
1252
1281
1282
1283
1284
1285
1286
1258
1287
1288
1289
1290
2 unmodified lines
1293
1294
1295
1267
1268
1296
1297
1298
1299
1300
1194 unmodified lines
return kept
}
// isProtectedCheckpointPath reports whether a repo-relative path must be kept
// out of checkpoint snapshots: the .entire infrastructure dir, or any
// registered agent's declared protected dir/file (e.g. .claude, or an external
// plugin's protected_dirs).
//
// This mirrors shouldIgnoreSessionTrackingPath in the cli package. The two
// cannot share an implementation because cli imports checkpoint, so the logic
// is duplicated deliberately. The first-checkpoint path (collectChangedFiles)
// must apply the same exclusions as the session-tracking and rewind paths, or
// protected-dir content is captured into the shadow tree on session start
// (see the DetectFileChanges / isProtectedPath call sites).
func isProtectedCheckpointPath(relPath string) bool {
cleanPath := filepath.Clean(filepath.FromSlash(relPath))
if paths.IsInfrastructurePath(cleanPath) {
return true
}
for _, file := range agent.AllProtectedFiles() {
if paths.Equal(cleanPath, file) {
return true
}
}
for _, dir := range agent.AllProtectedDirs() {
if paths.IsProtectedSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {
return true
}
}
return false
}
// collectChangedFiles returns all changed files from git status for the first checkpoint.
//
// For the first checkpoint, we need to capture:
45 unmodified lines
filename := entry[3:] // No TrimSpace needed with -z format
// Handle R/C (rename/copy) first - they have a second entry we must skip
// even if the new filename is an infrastructure path
// even if the new filename is a protected path
if staging == 'R' || staging == 'C' {
// Renamed or copied: current entry is new name, next entry is old name
if !paths.IsInfrastructurePath(filename) {
if !isProtectedCheckpointPath(filename) {
changedSeen[filename] = struct{}{}
}
// The old name follows as the next NUL-separated entry - must always skip it
if i+1 < len(entries) && entries[i+1] != "" {
oldName := entries[i+1]
if staging == 'R' && !paths.IsInfrastructurePath(oldName) {
if staging == 'R' && !isProtectedCheckpointPath(oldName) {
// For renames, old file is effectively deleted
deletedSeen[oldName] = struct{}{}
}
2 unmodified lines
continue
}
// Skip .entire directory for non-R/C entries
if paths.IsInfrastructurePath(filename) {
// Skip .entire and agent-protected dirs/files for non-R/C entries
if isProtectedCheckpointPath(filename) {
continue
}
Mcmd/entire/cli/checkpoint/ephemeral.go+34/-5
7 unmodified lines
8
9
10
11
12
13
14
19 unmodified lines
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
318 unmodified lines
521
522
523
524
525
526
527
528
529
530
531
7 unmodified lines
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
19 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)
// Keep needles auth-specific. Do not match git's generic
// "Could not read from remote repository" epilogue — that also appears on
// network failures where an ssh-agent hint would be wrong. Real auth
// failures always include a Permission denied / auth-methods line too.
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
}
}
// 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
// "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)
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+169
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
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1088 unmodified lines
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...)
}
func TestWithBatchModeSSH(t *testing.T) {
t.Parallel()
// gitConfigFile writes a minimal gitconfig with core.sshCommand set and
// returns a GIT_CONFIG_GLOBAL env entry pointing at it.
gitConfigFile := func(t *testing.T, sshCommand string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "gitconfig")
content := fmt.Sprintf("[core]\n\tsshCommand = %s\n", sshCommand)
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return "GIT_CONFIG_GLOBAL=" + path
}
tests := []struct {
name string
in func(t *testing.T) []string
want string
}{
{
name: "no existing GIT_SSH_COMMAND or config defaults to ssh",
in: func(t *testing.T) []string { return isolatedSSHEnv(t) },
want: "ssh -o BatchMode=yes",
},
{
name: "preserves and extends a custom ssh command",
in: func(t *testing.T) []string {
return isolatedSSHEnv(t, "GIT_SSH_COMMAND=ssh -i /home/me/.ssh/id")
},
want: "ssh -i /home/me/.ssh/id -o BatchMode=yes",
},
{
name: "GIT_SSH_COMMAND with explicit BatchMode=yes is left untouched",
in: func(t *testing.T) []string {
return isolatedSSHEnv(t, "GIT_SSH_COMMAND=ssh -o BatchMode=yes")
},
want: "ssh -o BatchMode=yes",
},
{
name: "GIT_SSH_COMMAND with explicit BatchMode=no is respected, not overridden",
in: func(t *testing.T) []string {
return isolatedSSHEnv(t, "GIT_SSH_COMMAND=ssh -o BatchMode=no")
},
want: "ssh -o BatchMode=no",
},
{
name: "blank GIT_SSH_COMMAND falls back to ssh",
in: func(t *testing.T) []string {
return isolatedSSHEnv(t, "GIT_SSH_COMMAND= ")
},
want: "ssh -o BatchMode=yes",
},
{
name: "core.sshCommand git config is used as the base when env is unset",
in: func(t *testing.T) []string {
cfg := gitConfigFile(t, "ssh -i /home/me/.ssh/work_key")
return isolatedSSHEnv(t, cfg)
},
want: "ssh -i /home/me/.ssh/work_key -o BatchMode=yes",
},
{
name: "GIT_SSH_COMMAND env takes precedence over core.sshCommand config",
in: func(t *testing.T) []string {
cfg := gitConfigFile(t, "ssh -i /home/me/.ssh/work_key")
return isolatedSSHEnv(t, cfg, "GIT_SSH_COMMAND=ssh -i /home/me/.ssh/personal_key")
},
want: "ssh -i /home/me/.ssh/personal_key -o BatchMode=yes",
},
{
name: "GIT_SSH is used only when neither env GIT_SSH_COMMAND nor config are set",
in: func(t *testing.T) []string {
return isolatedSSHEnv(t, "GIT_SSH=/usr/local/bin/custom-ssh")
},
want: "/usr/local/bin/custom-ssh -o BatchMode=yes",
},
{
name: "unrelated substring containing BatchMode-like text does not count as explicit",
in: func(t *testing.T) []string {
return isolatedSSHEnv(t, `GIT_SSH_COMMAND=ssh -o ProxyCommand="connect -H proxy NoBatchModeHereEither"`)
},
want: `ssh -o ProxyCommand="connect -H proxy NoBatchModeHereEither" -o BatchMode=yes`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
out := withBatchModeSSH(context.Background(), tt.in(t))
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()
env := isolatedSSHEnv(t, "GIT_SSH_COMMAND=ssh")
env = append(env, "SOME_OTHER_VAR=value")
out := withBatchModeSSH(context.Background(), env)
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, "value", m["SOME_OTHER_VAR"])
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")
})
}
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).\r\nfatal: Could not read from remote repository.", true},
{"fatal: Could not read from remote repository.", false}, // generic transport epilogue, not auth
{"ssh: connect to host example.com port 22: Connection refused\nfatal: Could not read from remote repository.", false},
{"enter passphrase for key '/home/me/.ssh/id_rsa':", false},
{"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_test.go+185
24 unmodified lines
25
26
27
28
28
29
30
31
24 unmodified lines
func isCheckpointPolicyWarningExcludedCommand(name string) bool {
switch name {
case "hooks", "__send_analytics", "curl-bash-post-install":
case "hooks", "__send_analytics", "__refresh_trail_enablement", "curl-bash-post-install":
return true
default:
return false
Mcmd/entire/cli/checkpoint_policy_warning.go+1/-1
46 unmodified lines
47
48
49
50
51
52
53
54
55
56
57
58
46 unmodified lines
sendAnalytics := &cobra.Command{Use: "__send_analytics", Hidden: true}
root.AddCommand(sendAnalytics)
refreshTrailEnablement := &cobra.Command{Use: "__refresh_trail_enablement", Hidden: true}
root.AddCommand(refreshTrailEnablement)
require.True(t, ShouldCheckCheckpointPolicyWarning(visible))
require.True(t, ShouldCheckCheckpointPolicyWarning(hiddenAlias))
require.False(t, ShouldCheckCheckpointPolicyWarning(gitHook))
require.False(t, ShouldCheckCheckpointPolicyWarning(sendAnalytics))
require.False(t, ShouldCheckCheckpointPolicyWarning(refreshTrailEnablement))
}
Mcmd/entire/cli/checkpoint_policy_warning_test.go+4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package execx
import (
"context"
"io"
"os"
"os/exec"
"testing"
)
// SpawnDetached re-execs the current executable as a detached, fire-and-forget
// child running args, surviving the parent's exit (new session on Unix,
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS on Windows, via detachFromTTY).
// The child runs in dir (os.TempDir() when empty, so the child never holds the
// parent's working directory), inherits the parent's environment, and has its
// stdout/stderr discarded. Best-effort: every error is swallowed — callers
// treat the spawn as advisory background work.
//
// In-process `go test` runs are a no-op: the current executable is the test
// binary, and re-execing it would fork the whole suite. Tests exercise the
// call sites through their spawn seams instead.
func SpawnDetached(dir string, args ...string) {
if testing.Testing() {
return
}
executable, err := os.Executable()
if err != nil {
return
}
// context.Background(): the child must outlive the parent, so it is never
// tied to a cancellable context.
cmd := exec.CommandContext(context.Background(), executable, args...)
detachFromTTY(cmd)
cmd.Dir = dir
if cmd.Dir == "" {
cmd.Dir = os.TempDir()
}
cmd.Env = os.Environ()
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
return
}
// Release the process so it can run independently of the parent.
//nolint:errcheck // best effort — the child continues regardless
_ = cmd.Process.Release()
}
Acmd/entire/cli/execx/spawn_detached.go+49
110 unmodified lines
111
112
113
114
114
115
116
117
118
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
6 unmodified lines
145
146
147
148
149
150
151
152
153
12 unmodified lines
166
167
168
169
170
171
172
173
174
110 unmodified lines
}
// experimentalCommandMarkers are substrings that only appear in root help when
// experimental commands are visible.
// experimental commands are visible. Do not pin cobra's Use/Short column
// padding — group membership and longest-command width shift the spaces.
var experimentalCommandMarkers = []string{
"Experimental commands:",
"review",
"tokens Analyze token usage across sessions and checkpoints",
}
// rootHelpHasTokensCommand reports whether root help lists the experimental
// `tokens` command with its Short description, ignoring Use/Short padding.
func rootHelpHasTokensCommand(got string) bool {
for _, line := range strings.Split(got, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "tokens" {
continue
}
if strings.Contains(line, "Analyze token usage across sessions and checkpoints") {
return true
}
}
return false
}
// TestRootHelp_ReleaseHidesExperimental verifies a shipped build
// (experimental.Visible="false") omits experimental commands and the group
// header from root help. Mutates the global gate, so it cannot run in parallel.
6 unmodified lines
t.Fatalf("release root help should not include %q, got:\n%s", marker, got)
}
}
if rootHelpHasTokensCommand(got) {
t.Fatalf("release root help should not list tokens, got:\n%s", got)
}
}
// TestRootHelp_DevShowsExperimentalGroup verifies a developer build
12 unmodified lines
t.Fatalf("dev root help should include %q, got:\n%s", marker, got)
}
}
if !rootHelpHasTokensCommand(got) {
t.Fatalf("dev root help should list tokens with its Short description, got:\n%s", got)
}
}
// summaryColumns returns, for each non-empty rendered row, the rune offset at
Mcmd/entire/cli/labs_test.go+23/-2
1 unmodified line
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2212 unmodified lines
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
1 unmodified line
import (
"context"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/opencode"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/investigate"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/review"
2212 unmodified lines
t.Fatalf("back-to-back checkpoint B after stale hook = %d, want 3", got)
}
}
// TestHandleLifecycleSessionStart_NoSynchronousNetworkForTrailEnablement
// guards against SessionStart hooks stalling agent startup: the
// trails-enablement cache refresh must be handed off to a detached subprocess,
// never performed inline on the SessionStart hook path. A slow/unreachable API
// host previously added up to trailEnablementSessionStartRefreshTimeout (1s) of
// synchronous latency to every session start once the hourly cache went stale.
//
// The deterministic guarantee is the spawn seam: SessionStart must invoke the
// detached-refresh spawn exactly once and return without doing the network work
// itself. As a production-shaped backstop the API base points at a blackholed
// https host that accepts the TCP connection but never answers — so a
// regression that dials inline both contacts that host (dialed > 0) and burns
// the ~1s session-start budget instead of returning immediately. (Plain http
// would be rejected by api.RequireSecureURL before any dial, so the host must
// be https to actually exercise the synchronous-dial path.)
func TestHandleLifecycleSessionStart_NoSynchronousNetworkForTrailEnablement(t *testing.T) {
setupStopTestRepo(t)
runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/entirehq/example.git")
// Blackhole https host: accept connections but never complete the TLS
// handshake or respond, so an inline dial stalls until a timeout fires
// (mirrors the unreachable-host case that motivated the detached refresh)
// rather than failing fast.
var dialed int32
var lc net.ListenConfig
ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
go func() {
for {
conn, acceptErr := ln.Accept()
if acceptErr != nil {
return
}
atomic.AddInt32(&dialed, 1)
_ = conn // hold open; never respond
}
}()
t.Setenv("ENTIRE_API_BASE_URL", "https://"+ln.Addr().String())
var spawnCount int32
prevSpawn := trailRefreshSpawn
trailRefreshSpawn = func(worktreeRoot string) {
atomic.AddInt32(&spawnCount, 1)
if worktreeRoot == "" {
t.Error("expected non-empty worktree root passed to trail refresh spawn")
}
}
t.Cleanup(func() { trailRefreshSpawn = prevSpawn })
ag := newMockHookResponseAgent()
event := &agent.Event{
Type: agent.SessionStart,
SessionID: "test-no-sync-trail-dial",
Timestamp: time.Now(),
}
start := time.Now()
err = handleLifecycleSessionStart(context.Background(), ag, event)
elapsed := time.Since(start)
require.NoError(t, err)
// Deterministic guarantee: the network-capable refresh is delegated to the
// detached spawn exactly once, never run inline.
if got := atomic.LoadInt32(&spawnCount); got != 1 {
t.Fatalf("expected exactly one detached trail-enablement refresh spawn, got %d", got)
}
// Backstops: SessionStart neither contacted the API host nor blocked.
if got := atomic.LoadInt32(&dialed); got != 0 {
t.Fatalf("SessionStart dialed the trails-enablement API synchronously; the refresh must run out of process")
}
if elapsed > time.Second {
t.Fatalf("handleLifecycleSessionStart took %v; trails-enablement refresh must be detached, not synchronous", elapsed)
}
}
// TestRunTrailEnablementRefresh_BoundedByTimeoutAgainstUnresponsiveHost
// verifies the deferred refresh work still completes (or at least
// gives up) within its own bounded timeout when the API host never
// responds — the network work that used to block SessionStart must still
// happen, just out of the hook's critical path, and it must not hang forever.
func TestRunTrailEnablementRefresh_BoundedByTimeoutAgainstUnresponsiveHost(t *testing.T) {
setupStopTestRepo(t)
runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/entirehq/example.git")
var lc net.ListenConfig
ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
var accepted int32
go func() {
for {
conn, acceptErr := ln.Accept()
if acceptErr != nil {
return
}
atomic.AddInt32(&accepted, 1)
// Accept the connection but never write anything back (no TLS
// handshake, no HTTP response) — simulates a blackholed/firewalled
// host, which is what triggered the original 1s stall per call.
_ = conn
}
}()
t.Setenv("ENTIRE_API_BASE_URL", "https://"+ln.Addr().String())
start := time.Now()
refreshErr := runTrailEnablementRefresh(context.Background())
elapsed := time.Since(start)
// Best-effort: network failure must not surface as a hard error.
require.NoError(t, refreshErr)
if elapsed > trailEnablementRefreshTimeout+2*time.Second {
t.Fatalf("runTrailEnablementRefresh took %v, expected to give up within roughly %v", elapsed, trailEnablementRefreshTimeout)
}
// Prove the test actually exercised the network path rather than passing
// via an early return (e.g. scope resolution or auth failing before any
// dial): the blackholed listener must have accepted at least one
// connection attempt.
if got := atomic.LoadInt32(&accepted); got == 0 {
t.Fatalf("expected at least one dial attempt against the unresponsive host, got %d", got)
}
}
// TestNewRefreshTrailEnablementCmd_APIFailureExitsZero guards against the
// detached __refresh_trail_enablement subprocess exiting non-zero on a
// transient network/API failure. The refresh is best-effort cache warming
// with stdout/stderr discarded (see newRefreshTrailEnablementCmd) — there is
// no one watching the exit code, so a failing TrailsEnabled call must be
// logged (already covered by TestRefreshTrailEnablementCmd_LogsBackgroundFailureToFile-
// style tests) and swallowed, never propagated as a command error, mirroring
// __send_analytics.
func TestNewRefreshTrailEnablementCmd_APIFailureExitsZero(t *testing.T) {
setupStopTestRepo(t)
runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/entirehq/example.git")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
prevClient := trailRefreshAPIClient
trailRefreshAPIClient = func(context.Context, bool) (*api.Client, error) {
return api.NewClientWithBaseURL("test-token", srv.URL), nil
}
t.Cleanup(func() { trailRefreshAPIClient = prevClient })
cmd := newRefreshTrailEnablementCmd()
cmd.SetArgs([]string{})
require.NoError(t, cmd.ExecuteContext(context.Background()),
"detached refresh command must exit 0 even when the API call fails (best-effort cache warming)")
}
// TestRefreshTrailEnablementCmd_LogsBackgroundFailureToFile guards
// diagnosability: the detached __refresh_trail_enablement child runs with
// stdout/stderr discarded, so a failing background refresh must still leave a
// trail in .entire/logs/entire.log instead of vanishing. The command runs in a
// repo with no origin remote, so the scope resolves-and-fails locally (no
// network) and that failure has to be logged to the repo's log file.
func TestRefreshTrailEnablementCmd_LogsBackgroundFailureToFile(t *testing.T) {
setupStopTestRepo(t)
t.Setenv("ENTIRE_LOG_LEVEL", "debug")
cmd := newRefreshTrailEnablementCmd()
cmd.SetArgs([]string{})
require.NoError(t, cmd.ExecuteContext(context.Background()))
root, err := paths.WorktreeRoot(context.Background())
require.NoError(t, err)
logData, err := os.ReadFile(filepath.Join(root, ".entire", "logs", "entire.log"))
require.NoError(t, err)
require.Contains(t, string(logData), "trails enablement refresh skipped: scope unresolved",
"background refresh failure must be diagnosable in .entire/logs/entire.log")
}
// TestRefreshTrailEnablementCmd_NoStrayLogsOutsideWorktree guards the file-init
// against running outside a resolvable worktree. logging.Init falls back to the
// current directory when paths.WorktreeRoot fails, so the command must guard on
// WorktreeRoot (as resume/rewind/reset/explain do) or a child whose worktree was
// removed/relocated between spawn and exec would MkdirAll a stray .entire/logs/
// wherever it happens to be running.
func TestRefreshTrailEnablementCmd_NoStrayLogsOutsideWorktree(t *testing.T) {
dir := t.TempDir() // a plain temp dir, not a git worktree
t.Chdir(dir)
paths.ClearWorktreeRootCache()
session.ClearGitCommonDirCache()
t.Setenv("ENTIRE_LOG_LEVEL", "debug")
cmd := newRefreshTrailEnablementCmd()
cmd.SetArgs([]string{})
require.NoError(t, cmd.ExecuteContext(context.Background()))
_, statErr := os.Stat(filepath.Join(dir, ".entire", "logs"))
require.True(t, os.IsNotExist(statErr),
"must not create a stray .entire/logs outside a resolvable worktree")
}
// TestTrailRefreshRecentlySpawned_ThrottlesWithinWindow verifies the spawn-side
// guard: within trailRefreshSpawnThrottle of a recorded spawn,
// further spawns are suppressed; once the window passes a fresh spawn is allowed
// and re-recorded. Without this, an unreachable host — which never writes the
// cache, so the hourly TTL never starts — would fork a refresh child on every
// SessionStart.
func TestTrailRefreshRecentlySpawned_ThrottlesWithinWindow(t *testing.T) {
commonDir := t.TempDir()
now := time.Now()
require.False(t, trailRefreshRecentlySpawned(commonDir, now),
"first call records the spawn and is not throttled")
require.True(t, trailRefreshRecentlySpawned(commonDir, now.Add(time.Second)),
"a second attempt within the window is throttled")
require.False(t, trailRefreshRecentlySpawned(commonDir, now.Add(trailRefreshSpawnThrottle)),
"at the window boundary the spawn is allowed and re-recorded")
require.True(t, trailRefreshRecentlySpawned(commonDir, now.Add(trailRefreshSpawnThrottle+time.Second)),
"an attempt within the window of the re-recorded spawn is throttled")
}
// TestSpawnDetachedTrailEnablementRefresh_CollapsesBurst verifies the throttle is
// actually wired into the spawn path: a burst of SessionStart-driven attempts for
// the same repo forks a single child, not one per hook.
func TestSpawnDetachedTrailEnablementRefresh_CollapsesBurst(t *testing.T) {
setupStopTestRepo(t)
var spawnCount int32
prevSpawn := trailRefreshSpawn
trailRefreshSpawn = func(string) { atomic.AddInt32(&spawnCount, 1) }
t.Cleanup(func() { trailRefreshSpawn = prevSpawn })
spawnDetachedTrailEnablementRefresh(context.Background())
spawnDetachedTrailEnablementRefresh(context.Background())
spawnDetachedTrailEnablementRefresh(context.Background())
if got := atomic.LoadInt32(&spawnCount); got != 1 {
t.Fatalf("expected the burst to collapse to a single detached spawn, got %d", got)
}
}
Mcmd/entire/cli/lifecycle_test.go+241
20 unmodified lines
21
22
23
24
25
26
27
106 unmodified lines
134
135
136
136
137
138
139
140
141
138
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
2 unmodified lines
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
20 unmodified lines
EntireMetadataDir = ".entire/metadata"
osWindows = "windows"
osDarwin = "darwin"
)
// Metadata file names
106 unmodified lines
}
// IsInfrastructurePath returns true if the path is part of CLI infrastructure
// (i.e., inside the .entire directory)
// (i.e., inside the .entire directory). It is used only to EXCLUDE infra paths
// from checkpoints/tracking, so it matches case-insensitively on
// case-insensitive filesystems via IsProtectedSubpath. Do not use it as a
// containment/allow gate.
func IsInfrastructurePath(path string) bool {
return IsSubpath(EntireDir, path)
return IsProtectedSubpath(EntireDir, path)
}
// IsSubpath reports whether child is lexically under parent (or equal to it).
// It uses filepath.Rel, which cleans both inputs and is traversal-resistant:
// a crafted child like "/a/b/../../../etc/passwd" that escapes parent will
// produce a relative path starting with ".." and be rejected.
//
// Matching is case-SENSITIVE. This is the correct primitive for fail-closed
// containment/allow checks (e.g. validating an attacker-influenced path stays
// under an Entire-owned dir): on a case-sensitive volume a differently-cased
// path names a different directory, so folding it in would fail open. For
// EXCLUSION decisions that must also catch case variants on Windows/macOS, use
// IsProtectedSubpath instead.
func IsSubpath(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
2 unmodified lines
return !IsRelativeTraversal(rel)
}
// IsProtectedSubpath reports whether child is under parent for the purpose of
// EXCLUDING protected/infrastructure content from checkpoints and tracking.
// Unlike IsSubpath it honors OS case-insensitivity (see CaseInsensitiveFS), so
// a case variant of a protected dir (".Claude" vs ".claude") is still excluded
// on Windows/macOS.
//
// SECURITY: never use this for allow/containment decisions. Case-folding widens
// what counts as "inside" parent, which is safe only when the effect is to
// exclude more. On a case-sensitive volume under a case-insensitive GOOS it
// over-matches; for a fail-closed gate that would fail open. Use IsSubpath there.
func IsProtectedSubpath(parent, child string) bool {
if CaseInsensitiveFS() {
return IsSubpath(strings.ToLower(parent), strings.ToLower(child))
}
return IsSubpath(parent, child)
}
// CaseInsensitiveFS reports whether path comparisons should be case-insensitive
// on the host OS. This is OS-based, not volume-based: Windows and macOS default
// to case-insensitive filesystems, Linux to case-sensitive. Keying on GOOS keeps
// the result deterministic. It must only influence EXCLUSION decisions (see
// IsProtectedSubpath / Equal): on an atypical volume (e.g. a case-sensitive
// macOS APFS volume) it treats a differently-cased path as matching, which is
// safe only when the effect is to exclude more, never to widen an allow gate.
func CaseInsensitiveFS() bool {
return runtime.GOOS == osWindows || runtime.GOOS == osDarwin
}
// Equal reports whether two paths refer to the same location, honoring the host
// OS's case sensitivity (see CaseInsensitiveFS). Both inputs are cleaned and
// slash-normalized before comparison. Like IsProtectedSubpath, this is intended
// for EXCLUSION matching (e.g. protected files), not fail-closed containment.
func Equal(a, b string) bool {
a = filepath.Clean(filepath.FromSlash(a))
b = filepath.Clean(filepath.FromSlash(b))
if CaseInsensitiveFS() {
return strings.EqualFold(a, b)
}
return a == b
}
// IsRelativeTraversal reports whether rel escapes its base directory.
// It accepts both OS-native paths and Git-style slash-normalized paths.
func IsRelativeTraversal(rel string) bool {
Mcmd/entire/cli/paths/paths.go+54/-2
95 unmodified lines
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
95 unmodified lines
}
}
func TestCaseInsensitiveFS(t *testing.T) {
t.Parallel()
want := runtime.GOOS == osWindows || runtime.GOOS == osDarwin
if got := CaseInsensitiveFS(); got != want {
t.Errorf("CaseInsensitiveFS() = %v, want %v (GOOS=%s)", got, want, runtime.GOOS)
}
}
// TestIsSubpath_AlwaysCaseSensitive locks in that IsSubpath — the fail-closed
// containment primitive used by allow gates (rewind/utils) — never folds case
// on any OS. A differently-cased path must not count as contained, or a
// crafted, attacker-influenced value could fail open on a case-sensitive volume.
func TestIsSubpath_AlwaysCaseSensitive(t *testing.T) {
t.Parallel()
if IsSubpath(".entire/metadata", ".Entire/metadata") {
t.Error("IsSubpath must be case-sensitive (fail-closed); .Entire/metadata must not be under .entire/metadata")
}
if !IsSubpath(".claude", ".claude/marker.txt") {
t.Error("IsSubpath(.claude, .claude/marker.txt) = false, want true")
}
if IsSubpath(".claude", ".claude/../../etc/passwd") {
t.Error("IsSubpath must reject traversal")
}
}
// TestIsProtectedSubpath_CaseSensitivity asserts OS-based folding for the
// EXCLUSION helper: case variants match on Windows/macOS (where they name the
// same on-disk path), stay distinct on case-sensitive Linux, and traversal is
// always rejected.
func TestIsProtectedSubpath_CaseSensitivity(t *testing.T) {
t.Parallel()
got := IsProtectedSubpath(".claude", ".Claude/marker.txt")
if got != CaseInsensitiveFS() {
t.Errorf("IsProtectedSubpath(.claude, .Claude/marker.txt) = %v, want %v (GOOS=%s)",
got, CaseInsensitiveFS(), runtime.GOOS)
}
if !IsProtectedSubpath(".claude", ".claude/marker.txt") {
t.Error("IsProtectedSubpath(.claude, .claude/marker.txt) = false, want true")
}
if IsProtectedSubpath(".claude", ".Claude/../../etc/passwd") {
t.Error("IsProtectedSubpath must reject traversal even when case-folding")
}
}
func TestEqual_CaseSensitivity(t *testing.T) {
t.Parallel()
if !Equal(".terminalhirerc", ".terminalhirerc") {
t.Error("Equal should match identical paths")
}
got := Equal(".terminalhirerc", ".TerminalHireRC")
if got != CaseInsensitiveFS() {
t.Errorf("Equal(case variant) = %v, want %v (GOOS=%s)",
got, CaseInsensitiveFS(), runtime.GOOS)
}
if Equal(".terminalhirerc", "other") {
t.Error("Equal should not match distinct paths")
}
}
func TestToRelativePath_MSYSPaths(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
Mcmd/entire/cli/paths/paths_test.go+59
74 unmodified lines
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
74 unmodified lines
metadataDir: ".entire",
want: "",
},
{
// Containment is a fail-closed allow gate: it must stay case-SENSITIVE
// on every OS. A case variant names a different on-disk dir on a
// case-sensitive volume (which exists under GOOS=darwin), so folding it
// in would fail open. Must return "" regardless of platform.
name: "case-variant of metadata dir fails closed on all OSes",
metadataDir: ".Entire/metadata/sess-123",
want: "",
},
}
for _, tt := range tests {
Mcmd/entire/cli/rewind_test.go+9
164 unmodified lines
165
166
167
168
169
170
171
164 unmodified lines
cmd.AddCommand(newTrailCmd())
cmd.AddCommand(newSendAnalyticsCmd())
cmd.AddCommand(newCurlBashPostInstallCmd())
cmd.AddCommand(newRefreshTrailEnablementCmd())
// Experimental command (developer-only visibility; setup/tune runners).
experimental.Register(cmd, newRunnerCmd()) // 'runner' (experimental)
Mcmd/entire/cli/root.go+1
237 unmodified lines
238
239
240
241
242
241
242
243
244
245
246
247
249
248
249
250
251
237 unmodified lines
}
for _, file := range agent.AllProtectedFiles() {
cleanFile := filepath.Clean(filepath.FromSlash(file))
if cleanPath == cleanFile {
if paths.Equal(cleanPath, file) {
return true
}
}
for _, dir := range agent.AllProtectedDirs() {
cleanDir := filepath.Clean(filepath.FromSlash(dir))
if paths.IsSubpath(cleanDir, cleanPath) {
if paths.IsProtectedSubpath(cleanDir, cleanPath) {
return true
}
}
Mcmd/entire/cli/state.go+2/-3
351 unmodified lines
352
353
354
355
355
356
357
358
351 unmodified lines
// registered agent config directories.
func isProtectedPath(relPath string) bool {
for _, dir := range protectedDirs() {
if paths.IsSubpath(dir, relPath) {
if paths.IsProtectedSubpath(dir, relPath) {
return true
}
}
Mcmd/entire/cli/strategy/common.go+1/-1
13 unmodified lines
14
15
16
17
18
19
20
28 unmodified lines
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
291 unmodified lines
360
361
362
348
363
364
365
366
367
3 unmodified lines
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
7 unmodified lines
394
395
396
397
398
399
400
401
402
13 unmodified lines
"github.com/go-git/go-git/v6/plumbing"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
checkpointremote "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/perf"
28 unmodified lines
}
func (s *ManualCommitStrategy) prePush(ctx context.Context, remote string, protectFirstUserBranch bool) error {
// This runs inside the user's `git push` pre-push hook. Every checkpoint
// git subprocess spawned here (metadata fetch, policy sync, checkpoint
// push and its recovery fetch) must fail fast rather than block on an
// interactive SSH passphrase prompt — there is no way to answer it here and
// it would hang the user's push. Foreground commands do not set this.
//
// BatchMode=yes suppresses passphrase/PIN prompts (including FIDO2
// verify-required PIN entry). Touch-only security keys still work because
// user-presence touch is not a terminal read. Users who need a PIN prompt
// in this path should load the key into ssh-agent, or set an explicit
// BatchMode=no via GIT_SSH_COMMAND / core.sshCommand (respected by the
// non-interactive SSH helper).
ctx = checkpointremote.WithNonInteractiveSSH(ctx)
// Load settings once for remote resolution and push_sessions check.
// Spanned because checkpoint-remote resolution can perform a one-time
// network fetch of the metadata branch (fetchMetadataBranchIfMissing),
291 unmodified lines
// Fast path: push all refs in one round-trip (fast-forward-only). If every
// ref was up to date or fast-forwarded, we're done.
if err := batchPushRefs(pushCtx, pushTarget, existing); err == nil {
batchErr := batchPushRefs(pushCtx, pushTarget, existing)
if batchErr == nil {
stop(" done")
if removeErr := queue.Remove(existing); removeErr != nil {
logging.Warn(ctx, "git-refs push: clear pushed refs from queue failed",
3 unmodified lines
}
stop("")
// Non-interactive SSH auth failures cannot be fixed by per-ref
// fetch+replay. Surface the same actionable hint as the v1 doPushRef path
// (issue #1523) instead of only logging to .entire/logs/.
if nonInteractiveSSHAuthFailure(pushCtx, batchErr) {
fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't push checkpoint refs: %v\n", batchErr)
printNonInteractiveSSHAuthHint()
printCheckpointRemoteHint(pushTarget)
return 0, batchErr
}
// At least one ref was rejected — typically a non-fast-forward divergence
// (the same checkpoint re-written on another machine). Retry per ref with
// fetch+replay recovery, and remove from the queue only the refs that land
7 unmodified lines
if err := pushCheckpointRefWithRecovery(pushCtx, pushTarget, ref); err != nil {
logging.Warn(ctx, "git-refs push: checkpoint ref push/sync failed; left queued, not overwritten",
slog.String("ref", ref.String()), slog.String("error", err.Error()))
if nonInteractiveSSHAuthFailure(pushCtx, err) {
printNonInteractiveSSHAuthHint()
}
if firstErr == nil {
firstErr = err
}
Mcmd/entire/cli/strategy/manual_commit_push.go+30/-1
177 unmodified lines
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
7 unmodified lines
201
202
203
204
205
206
207
208
209
6 unmodified lines
216
217
218
219
220
221
222
223
224
11 unmodified lines
236
237
238
239
240
241
242
243
244
245
246
247
248
4 unmodified lines
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
177 unmodified lines
return nil
}
// 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 nonInteractiveSSHAuthFailure(ctx, err) {
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".
7 unmodified lines
if syncErr != nil {
stop("")
fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", refLabel, syncErr)
if nonInteractiveSSHAuthFailure(ctx, syncErr) {
printNonInteractiveSSHAuthHint()
}
printCheckpointRemoteHint(target)
return nil // Don't fail the main push
}
6 unmodified lines
if result, err := tryPushRefCommon(ctx, target, ref); err != nil {
stop("")
fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", refLabel, err)
if nonInteractiveSSHAuthFailure(ctx, err) {
printNonInteractiveSSHAuthHint()
}
printCheckpointRemoteHint(target)
} else {
finishPush(ctx, stop, result, target)
11 unmodified lines
return ref.String()
}
// nonInteractiveSSHAuthFailure reports whether err is an SSH auth-shaped
// failure under a BatchMode (non-interactive) context. Used to print the
// actionable ssh-agent hint and skip useless recovery retries.
func nonInteractiveSSHAuthFailure(ctx context.Context, err error) bool {
return err != nil && remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(err.Error())
}
// printCheckpointRemoteHint prints a hint when a push to a checkpoint URL fails.
// Only prints when the target is a URL (not the user's default remote).
func printCheckpointRemoteHint(target string) {
4 unmodified lines
fmt.Fprintln(os.Stderr, "[entire] Checkpoints are saved locally but not synced. Ensure you have access to the checkpoint remote.")
}
// sshAuthHintOnce ensures the ssh-agent hint prints at most once per process
// (pre-push can push multiple refs).
var sshAuthHintOnce sync.Once
// printNonInteractiveSSHAuthHint tells the user how to unblock checkpoint pushes
// that failed because SSH needed interactive auth under BatchMode (issue #1523).
func printNonInteractiveSSHAuthHint() {
sshAuthHintOnce.Do(func() {
fmt.Fprintln(os.Stderr, "[entire] Checkpoint push skipped: SSH needs interactive auth (passphrase/PIN) and cannot prompt during git hooks.")
fmt.Fprintln(os.Stderr, "[entire] Load your key into ssh-agent (`ssh-add`), then push again. Checkpoints are saved locally until then.")
fmt.Fprintln(os.Stderr, "[entire] PIN-protected security keys: unlock/add them to the agent first. To allow prompts in this path, set GIT_SSH_COMMAND (or core.sshCommand) with an explicit BatchMode=no.")
})
}
// settingsHintOnce ensures the settings commit hint prints at most once per process.
var settingsHintOnce sync.Once
Mcmd/entire/cli/strategy/push_common.go+37
3 unmodified lines
4
5
6
7
8
9
10
2 unmodified lines
13
14
15
16
17
18
19
1649 unmodified lines
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
3 unmodified lines
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
2 unmodified lines
"testing"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/testutil"
1649 unmodified lines
assert.NotContains(t, out, "git@github.com:org/repo.git")
})
}
func TestPrintNonInteractiveSSHAuthHint(t *testing.T) {
// 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")
}
func TestNonInteractiveSSHAuthFailure(t *testing.T) {
t.Parallel()
authErr := errors.New("permission denied (publickey)")
ctx := remote.WithNonInteractiveSSH(context.Background())
assert.True(t, nonInteractiveSSHAuthFailure(ctx, authErr))
assert.False(t, nonInteractiveSSHAuthFailure(context.Background(), authErr),
"interactive context must not treat auth errors as BatchMode hints")
assert.False(t, nonInteractiveSSHAuthFailure(ctx, errors.New("non-fast-forward")))
assert.False(t, nonInteractiveSSHAuthFailure(ctx, nil))
}
Mcmd/entire/cli/strategy/push_common_test.go+34
9 unmodified lines
10
11
12
13
14
15
16
69 unmodified lines
86
87
88
89
90
91
92
93
94
95
96
97
98
9 unmodified lines
"time"
"github.com/denisbrodbeck/machineid"
"github.com/entireio/cli/cmd/entire/cli/execx"
"github.com/posthog/posthog-go"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
69 unmodified lines
}
}
// spawnDetachedAnalytics sends the payload from a detached `entire
// __send_analytics` child so the network call never blocks the CLI. The empty
// dir keeps the child out of the parent's working directory.
func spawnDetachedAnalytics(payloadJSON string) {
execx.SpawnDetached("", "__send_analytics", payloadJSON)
}
// TrackCommandDetached tracks a command execution by spawning a detached subprocess.
// This returns immediately without blocking the CLI.
func TrackCommandDetached(cmd *cobra.Command, agent string, isEntireEnabled bool, version string) {
Mcmd/entire/cli/telemetry/detached.go+8
1
2
3
4
5
6
7
8
9
10
11
//go:build !unix && !windows
package telemetry
// spawnDetachedAnalytics is a no-op on non-Unix platforms.
// Windows support for detached processes would require different syscall flags
// (CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS), but telemetry is best-effort
// so we simply skip it on unsupported platforms.
func spawnDetachedAnalytics(string) {
// No-op: detached subprocess spawning not implemented for this platform
}
Dcmd/entire/cli/telemetry/detached_other.go-11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//go:build unix
package telemetry
import (
"context"
"io"
"os"
"os/exec"
"syscall"
)
// spawnDetachedAnalytics spawns a detached subprocess to send analytics.
// On Unix, this uses process group detachment so the subprocess continues
// after the parent exits.
func spawnDetachedAnalytics(payloadJSON string) {
executable, err := os.Executable()
if err != nil {
return
}
cmd := exec.CommandContext(context.Background(), executable, "__send_analytics", payloadJSON)
// Detach from parent process group so subprocess survives parent exit
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
// Don't hold the working directory
cmd.Dir = "/"
// Inherit environment (may be needed for network config)
cmd.Env = os.Environ()
// Discard stdout/stderr to prevent output leaking to parent's terminal
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
// Start the process (non-blocking)
if err := cmd.Start(); err != nil {
return
}
// Release the process so it can run independently
//nolint:errcheck // Best effort - process should continue regardless
_ = cmd.Process.Release()
}
Dcmd/entire/cli/telemetry/detached_unix.go-47
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//go:build windows
package telemetry
import (
"context"
"io"
"os"
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
// spawnDetachedAnalytics spawns a detached subprocess to send analytics.
// On Windows, this uses CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS flags
// so the subprocess continues after the parent exits.
func spawnDetachedAnalytics(payloadJSON string) {
executable, err := os.Executable()
if err != nil {
return
}
cmd := exec.CommandContext(context.Background(), executable, "__send_analytics", payloadJSON)
// Detach from parent console so subprocess survives parent exit.
// CREATE_NEW_PROCESS_GROUP: own Ctrl+C group (prevents signal propagation).
// DETACHED_PROCESS: fully detach from parent's console.
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
}
// Use temp dir since "/" doesn't exist on Windows
cmd.Dir = os.TempDir()
// Inherit environment (may be needed for network config)
cmd.Env = os.Environ()
// Discard stdout/stderr to prevent output leaking to parent's terminal
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
// Start the process (non-blocking)
if err := cmd.Start(); err != nil {
return
}
// Release the process so it can run independently
//nolint:errcheck // Best effort - process should continue regardless
_ = cmd.Process.Release()
}
Dcmd/entire/cli/telemetry/detached_windows.go-51
12 unmodified lines
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
170 unmodified lines
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
1 unmodified line
216
217
218
205
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
207
246
247
248
209
210
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
12 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/cmd/entire/cli/execx"
"github.com/entireio/cli/cmd/entire/cli/gitremote"
"github.com/entireio/cli/cmd/entire/cli/internal/flock"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/validation"
"github.com/spf13/cobra"
)
const (
170 unmodified lines
return nil
}
// refreshTrailsEnabledCacheIfStaleForScope refreshes the trails-enablement
// cache when it's unknown/expired for scope. Callers on hot, latency-sensitive
// paths (SessionStart) must not block on this: resolving the API token and
// dialing TrailsEnabled can stall for seconds when the host is slow or
// unreachable (VPN, firewall, offline). Instead of doing that
// network work inline, hand it off to a detached `__refresh_trail_enablement`
// subprocess and return immediately; a later SessionStart will observe the
// freshly written cache once the subprocess completes. The "not supported"
// case is answered locally (no network) since it's free.
func refreshTrailsEnabledCacheIfStaleForScope(ctx context.Context, scope trailEnablementScope) error {
if cachedTrailsEnablementForScope(ctx, scope, time.Now()) != trailEnablementCacheUnknown {
return nil
1 unmodified line
if !scope.Supported {
return saveTrailsEnabledForScope(ctx, scope, false, time.Now())
}
client, err := NewAuthenticatedAPIClient(ctx, false)
spawnDetachedTrailEnablementRefresh(ctx)
return nil
}
// trailRefreshAPIClient is the authenticated-client seam used by
// runTrailEnablementRefresh, swapped in tests so they can force the
// refreshTrailsEnabledCacheForScope error branch (e.g. a broken API host)
// without a real login context. Production code always uses
// NewAuthenticatedAPIClient.
var trailRefreshAPIClient = NewAuthenticatedAPIClient
// runTrailEnablementRefresh performs the actual (potentially slow) network
// refresh. It is invoked from the detached `__refresh_trail_enablement`
// subprocess spawned by refreshTrailsEnabledCacheIfStaleForScope, never
// synchronously from a hook path.
func runTrailEnablementRefresh(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout)
defer cancel()
// This runs detached with stdout/stderr discarded, so log at debug to the
// repo's .entire/logs/entire.log (initialized by newRefreshTrailEnablementCmd).
// Without this, an unreachable/failing host would leave the background
// refresh silently failing with no diagnostic trail.
logCtx := logging.WithComponent(ctx, "trail-refresh")
scope, err := currentTrailEnablementScope(ctx)
if err != nil {
return err
logging.Debug(logCtx, "trails enablement refresh skipped: scope unresolved", "error", err.Error())
return nil
}
_, err = refreshTrailsEnabledCacheForScope(ctx, client, scope)
return err
// Another process (e.g. a fast-following SessionStart, or a concurrent
// refresh already in flight) may have populated the cache first.
if cachedTrailsEnablementForScope(ctx, scope, time.Now()) != trailEnablementCacheUnknown {
return nil
}
if !scope.Supported {
if err := saveTrailsEnabledForScope(ctx, scope, false, time.Now()); err != nil {
logging.Debug(logCtx, "trails enablement refresh failed to save unsupported scope", "error", err.Error())
}
return nil
}
client, err := trailRefreshAPIClient(ctx, false)
if err != nil {
logging.Debug(logCtx, "trails enablement refresh skipped: authenticated client unavailable", "error", err.Error())
return nil
}
// Best-effort: this runs from the detached __refresh_trail_enablement
// subprocess (stdout/stderr discarded, see newRefreshTrailEnablementCmd),
// so a transient network/API failure here must not surface as a non-zero
// process exit — there's no one watching it and no user-visible benefit,
// only a spurious failure signal. The failure is still diagnosable via the
// debug log above.
if _, err := refreshTrailsEnabledCacheForScope(ctx, client, scope); err != nil {
logging.Debug(logCtx, "trails enablement refresh failed", "error", err.Error())
return nil
}
logging.Debug(logCtx, "trails enablement refresh completed", "enabled_repo_key", scope.RepoKey)
return nil
}
// trailRefreshSpawn is the process-spawn seam used by
// spawnDetachedTrailEnablementRefresh. Swapped in tests so they can assert
// SessionStart never blocks on it without forking a real subprocess (a real
// `go test` binary doesn't understand `__refresh_trail_enablement` as an
// argument). Production code always uses spawnDetachedTrailRefreshProcess.
var trailRefreshSpawn = spawnDetachedTrailRefreshProcess
// spawnDetachedTrailRefreshProcess starts `entire __refresh_trail_enablement`
// as a detached child so the trails-enablement network refresh can't add
// latency to the SessionStart hook that spawned it. The child runs from the
// worktree root because the refresh resolves the origin remote and
// git-common-dir for cache storage from its working directory.
func spawnDetachedTrailRefreshProcess(worktreeRoot string) {
execx.SpawnDetached(worktreeRoot, "__refresh_trail_enablement")
}
// trailRefreshSpawnThrottle bounds how often SessionStart forks a detached
// refresh child for a given repo. When the API host is unreachable the refresh
// never writes the cache, so cachedTrailsEnablementForScope stays unknown and
// the hourly TTL never starts — without this guard every SessionStart (and
// every concurrent worktree) would fork a fresh child that re-opens the repo,
// re-resolves auth, and re-dials the dead host. Tying the window to the child's
// own timeout collapses a burst of hooks to roughly one child per window while
// still retrying promptly once the host recovers.
const trailRefreshSpawnThrottle = trailEnablementRefreshTimeout
// spawnDetachedTrailEnablementRefresh starts a detached child process that
// runs runTrailEnablementRefresh in the background. Best-effort: if the
// worktree root can't be resolved or the subprocess can't be spawned, the
// cache simply stays unknown and the next SessionStart tries again. A recent
// spawn for the same repo short-circuits so a burst of hooks doesn't fork a
// herd of redundant refresh children (see trailRefreshRecentlySpawned).
func spawnDetachedTrailEnablementRefresh(ctx context.Context) {
worktreeRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return
}
if commonDir, err := session.GetGitCommonDir(ctx); err == nil &&
trailRefreshRecentlySpawned(commonDir, time.Now()) {
return
}
trailRefreshSpawn(worktreeRoot)
}
// trailRefreshRecentlySpawned reports whether a detached refresh was spawned for
// this repo within trailRefreshSpawnThrottle and, when it wasn't, records now as
// the most recent spawn. The read-and-record is serialized with a flock keyed to
// the shared git-common-dir (so every worktree of the repo agrees), collapsing a
// burst of concurrent SessionStart hooks to a single child rather than one per
// hook. Best-effort: any error resolving, locking, or writing the marker falls
// through to spawning — never worse than before this guard existed.
func trailRefreshRecentlySpawned(commonDir string, now time.Time) bool {
dir := filepath.Join(commonDir, "entire")
// Create the directory before acquiring the lock: flock.Acquire opens the
// lock file, which fails if its parent doesn't exist yet (mirrors
// ModifyClonePreferences, which MkdirAlls before locking).
if err := os.MkdirAll(dir, 0o750); err != nil {
return false
}
markerPath := filepath.Join(dir, "trail-refresh-spawn")
release, err := flock.Acquire(markerPath + ".lock")
if err != nil {
return false
}
defer release()
if data, readErr := os.ReadFile(markerPath); readErr == nil { //nolint:gosec // markerPath is derived from the trusted git-common-dir, not user input
if last, parseErr := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(data))); parseErr == nil &&
now.After(last) && now.Sub(last) < trailRefreshSpawnThrottle {
return true
}
}
//nolint:errcheck // best-effort marker; a failed write just means the next hook re-spawns
_ = os.WriteFile(markerPath, []byte(now.UTC().Format(time.RFC3339Nano)), 0o600)
return false
}
// newRefreshTrailEnablementCmd creates the hidden command that performs the
// (potentially slow) trails-enablement network refresh out of band. It is
// invoked by spawnDetachedTrailEnablementRefresh from a detached subprocess
// and should not be called directly.
func newRefreshTrailEnablementCmd() *cobra.Command {
return &cobra.Command{
Use: "__refresh_trail_enablement",
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Detached child with discarded stdout/stderr: initialize file
// logging so a failing background refresh (e.g. an unreachable
// host) is diagnosable in .entire/logs/entire.log rather than
// vanishing. Guard on WorktreeRoot first — matching resume/rewind/
// reset/explain — so a child whose worktree was removed or relocated
// between spawn and exec (or a manual invocation outside a repo)
// doesn't create a stray .entire/logs/ in an arbitrary directory;
// logging.Init falls back to cwd when WorktreeRoot fails.
if _, err := paths.WorktreeRoot(ctx); err == nil {
logging.SetLogLevelGetter(GetLogLevel)
if err := logging.Init(ctx, ""); err == nil {
defer logging.Close()
}
}
return runTrailEnablementRefresh(ctx)
},
}
}
func refreshTrailsEnabledCache(ctx context.Context, client *api.Client) (bool, error) {
Mcmd/entire/cli/trail_context_cache.go+177/-4
25 unmodified lines
26
27
28
29
29
30
31
32
25 unmodified lines
github.com/muesli/termenv v0.16.0
github.com/ogen-go/ogen v1.23.0
github.com/oklog/ulid/v2 v2.1.1
github.com/posthog/posthog-go v1.17.5
github.com/posthog/posthog-go v1.18.0
github.com/sergi/go-diff v1.4.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Mgo.mod+1/-1
244 unmodified lines
245
246
247
248
249
248
249
250
251
252
244 unmodified lines
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posthog/posthog-go v1.17.5 h1:mrLiAdyiQpl8Yeyg23iShyAztJMaUrYzsBjKeH52Aak=
github.com/posthog/posthog-go v1.17.5/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU=
github.com/posthog/posthog-go v1.18.0 h1:gCkHzRjGR0WFype95mVvCXn4AioGHdy9fdrmxxFIRls=
github.com/posthog/posthog-go v1.18.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
Mgo.sum+2/-2