detached f592943

fix(session): isolate UUID resume from corrupt transcripts (#166)

* fix(session): isolate UUID resume from corrupt transcripts

* fix(session): validate resume candidates with lstat
commit
f592943275e547f19c194f71b99500a39b198a04
author
bnema · · 2026-09-01 16:45 UTC
committer
GitHub · · 2026-09-01 16:45 UTC
parent
d8a14e7
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.28.0
7 files · +205 -23
M packages/agent/cli.go +10 -2
@@ -3007,8 +3007,9 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
30073007 }
30083008 // Sweep meta-only files left over from older zut versions (and from
30093009 // any session that crashed before its first AppendMessage) for the
3010- // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
3011- // can report metadata failures instead of turning them into misses.
3010+ // cwd-scoped picker paths. Explicit UUID lookup identifies candidates
3011+ // from their headers so unrelated transcript corruption does not block
3012+ // a valid resume.
30123013 sessionsRoot := agentSessionsRoot(ZutHome(), args)
30133014 if args.ResumeSessionID == "" {
30143015 core.PruneEmptySessions(sessionsRoot, args.CWD)
@@ -3042,6 +3043,13 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
30423043 return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
30433044 }
30443045 s, msgs, err = core.OpenSession(picked)
3046+ if err == nil && s.ID != args.ResumeSessionID {
3047+ closeErr := s.Close()
3048+ return nil, errors.Join(
3049+ fmt.Errorf("session %q changed during resume", args.ResumeSessionID),
3050+ closeErr,
3051+ )
3052+ }
30453053 break
30463054 }
30473055 picked, perr := pickSession(sessionsRoot, args.CWD)
@@ -3007,8 +3007,9 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
3007 }3007 }
3008 // Sweep meta-only files left over from older zut versions (and from3008 // Sweep meta-only files left over from older zut versions (and from
3009 // any session that crashed before its first AppendMessage) for the3009 // any session that crashed before its first AppendMessage) for the
3010- // cwd-scoped picker paths. Explicit UUID lookup stays strict so it3010+ // cwd-scoped picker paths. Explicit UUID lookup identifies candidates
3011- // can report metadata failures instead of turning them into misses.3011+ // from their headers so unrelated transcript corruption does not block
3012+ // a valid resume.
3012 sessionsRoot := agentSessionsRoot(ZutHome(), args)3013 sessionsRoot := agentSessionsRoot(ZutHome(), args)
3013 if args.ResumeSessionID == "" {3014 if args.ResumeSessionID == "" {
3014 core.PruneEmptySessions(sessionsRoot, args.CWD)3015 core.PruneEmptySessions(sessionsRoot, args.CWD)
@@ -3042,6 +3043,13 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
3042 return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)3043 return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
3043 }3044 }
3044 s, msgs, err = core.OpenSession(picked)3045 s, msgs, err = core.OpenSession(picked)
3046+ if err == nil && s.ID != args.ResumeSessionID {
3047+ closeErr := s.Close()
3048+ return nil, errors.Join(
3049+ fmt.Errorf("session %q changed during resume", args.ResumeSessionID),
3050+ closeErr,
3051+ )
3052+ }
3045 break3053 break
3046 }3054 }
3047 picked, perr := pickSession(sessionsRoot, args.CWD)3055 picked, perr := pickSession(sessionsRoot, args.CWD)
M packages/agent/cli_session_test.go +19 -8
@@ -555,27 +555,38 @@ func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
555555 }
556556 }
557557
558-func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
558+func TestOpenOrCreateSessionUUIDLookupIgnoresUnrelatedCorruption(t *testing.T) {
559559 root := t.TempDir()
560560 cwd := t.TempDir()
561561 t.Setenv("ZUT_HOME", root)
562- if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
562+ target, err := core.NewSession(root, cwd, "provider", "model", "test")
563+ if err != nil {
564+ t.Fatal(err)
565+ }
566+ if err := target.AppendMessage(provider.Message{
567+ Role: provider.RoleUser,
568+ Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
569+ }); err != nil {
570+ t.Fatal(err)
571+ }
572+ if err := target.Close(); err != nil {
563573 t.Fatal(err)
564574 }
565575 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
566576 t.Fatal(err)
567577 }
568578
569- _, err := openOrCreateSession(context.Background(), Args{
579+ session, err := openOrCreateSession(context.Background(), Args{
570580 CWD: cwd,
571581 Resume: true,
572- ResumeSessionID: uuid.NewString(),
582+ ResumeSessionID: target.ID,
573583 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
574- if err == nil {
575- t.Fatal("malformed UUID lookup unexpectedly succeeded")
584+ if err != nil {
585+ t.Fatalf("openOrCreateSession: %v", err)
576586 }
577- if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
578- t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
587+ defer session.Close()
588+ if session.Path != target.Path {
589+ t.Fatalf("resumed session path = %q, want %q", session.Path, target.Path)
579590 }
580591 }
581592
@@ -555,27 +555,38 @@ func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
555 }555 }
556 }556 }
557 557
558-func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {558+func TestOpenOrCreateSessionUUIDLookupIgnoresUnrelatedCorruption(t *testing.T) {
559 root := t.TempDir()559 root := t.TempDir()
560 cwd := t.TempDir()560 cwd := t.TempDir()
561 t.Setenv("ZUT_HOME", root)561 t.Setenv("ZUT_HOME", root)
562- if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {562+ target, err := core.NewSession(root, cwd, "provider", "model", "test")
563+ if err != nil {
564+ t.Fatal(err)
565+ }
566+ if err := target.AppendMessage(provider.Message{
567+ Role: provider.RoleUser,
568+ Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
569+ }); err != nil {
570+ t.Fatal(err)
571+ }
572+ if err := target.Close(); err != nil {
563 t.Fatal(err)573 t.Fatal(err)
564 }574 }
565 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {575 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
566 t.Fatal(err)576 t.Fatal(err)
567 }577 }
568 578
569- _, err := openOrCreateSession(context.Background(), Args{579+ session, err := openOrCreateSession(context.Background(), Args{
570 CWD: cwd,580 CWD: cwd,
571 Resume: true,581 Resume: true,
572- ResumeSessionID: uuid.NewString(),582+ ResumeSessionID: target.ID,
573 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")583 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
574- if err == nil {584+ if err != nil {
575- t.Fatal("malformed UUID lookup unexpectedly succeeded")585+ t.Fatalf("openOrCreateSession: %v", err)
576 }586 }
577- if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {587+ defer session.Close()
578- t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)588+ if session.Path != target.Path {
589+ t.Fatalf("resumed session path = %q, want %q", session.Path, target.Path)
579 }590 }
580 }591 }
581 592
M packages/agent/scheduler_runtime.go +7 -0
@@ -27,6 +27,13 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba
2727 if err != nil {
2828 return fmt.Errorf("open scheduled session: %w", err)
2929 }
30+ if sess.ID != task.SessionID {
31+ closeErr := sess.Close()
32+ if closeErr != nil {
33+ return fmt.Errorf("scheduled session %q changed during resume: %w", task.SessionID, closeErr)
34+ }
35+ return fmt.Errorf("scheduled session %q changed during resume", task.SessionID)
36+ }
3037 defer sess.Close()
3138
3239 providerName := strings.TrimSpace(sess.Meta.Provider)
@@ -27,6 +27,13 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba
27 if err != nil {27 if err != nil {
28 return fmt.Errorf("open scheduled session: %w", err)28 return fmt.Errorf("open scheduled session: %w", err)
29 }29 }
30+ if sess.ID != task.SessionID {
31+ closeErr := sess.Close()
32+ if closeErr != nil {
33+ return fmt.Errorf("scheduled session %q changed during resume: %w", task.SessionID, closeErr)
34+ }
35+ return fmt.Errorf("scheduled session %q changed during resume", task.SessionID)
36+ }
30 defer sess.Close()37 defer sess.Close()
31 38
32 providerName := strings.TrimSpace(sess.Meta.Provider)39 providerName := strings.TrimSpace(sess.Meta.Provider)
M packages/core/session_log.go +5 -0
@@ -29,6 +29,7 @@ type sessionLogRow struct {
2929 // content hydration. Callers only implement projection-specific reduction.
3030 func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessionLogRow) error) error {
3131 sawMeta := false
32+ sessionID := ""
3233 err := forEachStrictJSONLLineContext(ctx, r, func(line []byte, lineNo int) error {
3334 row, err := decodeSessionLogRow(line)
3435 if err != nil {
@@ -38,6 +39,10 @@ func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessi
3839 return fmt.Errorf("line %d: first row is not meta", lineNo)
3940 }
4041 if row.typeName == "meta" {
42+ if sessionID != "" && row.meta.ID != sessionID {
43+ return fmt.Errorf("line %d: session id changed from %q to %q", lineNo, sessionID, row.meta.ID)
44+ }
45+ sessionID = row.meta.ID
4146 sawMeta = true
4247 }
4348 return fn(row)
@@ -29,6 +29,7 @@ type sessionLogRow struct {
29 // content hydration. Callers only implement projection-specific reduction.29 // content hydration. Callers only implement projection-specific reduction.
30 func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessionLogRow) error) error {30 func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessionLogRow) error) error {
31 sawMeta := false31 sawMeta := false
32+ sessionID := ""
32 err := forEachStrictJSONLLineContext(ctx, r, func(line []byte, lineNo int) error {33 err := forEachStrictJSONLLineContext(ctx, r, func(line []byte, lineNo int) error {
33 row, err := decodeSessionLogRow(line)34 row, err := decodeSessionLogRow(line)
34 if err != nil {35 if err != nil {
@@ -38,6 +39,10 @@ func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessi
38 return fmt.Errorf("line %d: first row is not meta", lineNo)39 return fmt.Errorf("line %d: first row is not meta", lineNo)
39 }40 }
40 if row.typeName == "meta" {41 if row.typeName == "meta" {
42+ if sessionID != "" && row.meta.ID != sessionID {
43+ return fmt.Errorf("line %d: session id changed from %q to %q", lineNo, sessionID, row.meta.ID)
44+ }
45+ sessionID = row.meta.ID
41 sawMeta = true46 sawMeta = true
42 }47 }
43 return fn(row)48 return fn(row)
M packages/core/session_log_test.go +1 -0
@@ -28,6 +28,7 @@ func TestSessionLogProjectionsRejectInvalidRowsConsistently(t *testing.T) {
2828 {name: "invalid usage", contents: string(metaLine) + "\n" + `{"type":"usage","cumulative":null}` + "\n", want: "usage row has no cumulative usage"},
2929 {name: "invalid rename", contents: string(metaLine) + "\n" + `{"type":"rename"}` + "\n", want: "rename row has no title"},
3030 {name: "unknown type", contents: string(metaLine) + "\n" + `{"type":"future-row"}` + "\n", want: `unknown row type "future-row"`},
31+ {name: "changed session id", contents: string(metaLine) + "\n" + `{"type":"meta","meta":{"id":"session-2","cwd":"/workspace"}}` + "\n", want: `session id changed from "session-1" to "session-2"`},
3132 }
3233
3334 for _, test := range tests {
@@ -28,6 +28,7 @@ func TestSessionLogProjectionsRejectInvalidRowsConsistently(t *testing.T) {
28 {name: "invalid usage", contents: string(metaLine) + "\n" + `{"type":"usage","cumulative":null}` + "\n", want: "usage row has no cumulative usage"},28 {name: "invalid usage", contents: string(metaLine) + "\n" + `{"type":"usage","cumulative":null}` + "\n", want: "usage row has no cumulative usage"},
29 {name: "invalid rename", contents: string(metaLine) + "\n" + `{"type":"rename"}` + "\n", want: "rename row has no title"},29 {name: "invalid rename", contents: string(metaLine) + "\n" + `{"type":"rename"}` + "\n", want: "rename row has no title"},
30 {name: "unknown type", contents: string(metaLine) + "\n" + `{"type":"future-row"}` + "\n", want: `unknown row type "future-row"`},30 {name: "unknown type", contents: string(metaLine) + "\n" + `{"type":"future-row"}` + "\n", want: `unknown row type "future-row"`},
31+ {name: "changed session id", contents: string(metaLine) + "\n" + `{"type":"meta","meta":{"id":"session-2","cwd":"/workspace"}}` + "\n", want: `session id changed from "session-1" to "session-2"`},
31 }32 }
32 33
33 for _, test := range tests {34 for _, test := range tests {
M packages/core/session_portable.go +13 -7
@@ -963,8 +963,10 @@ func FindSessionByID(root, cwd, id string) string {
963963
964964 // FindManagedSessionByID looks up a session UUID across zut's managed
965965 // session stores, independent of cwd. A nil error and empty path mean no
966-// matching session was found. Read, storage, metadata, and duplicate-ID
967-// failures are returned to the caller instead of being reported as a miss.
966+// matching session was found. Directory enumeration, cancellation, and
967+// duplicate-ID failures are returned to the caller. Candidate files that
968+// cannot be inspected or whose headers are unreadable or invalid are ignored
969+// because they cannot be associated with a requested UUID.
968970 func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
969971 if strings.TrimSpace(root) == "" {
970972 return "", errors.New("find managed session: root is empty")
@@ -1024,7 +1026,7 @@ func collectManagedSessionMatches(ctx context.Context, root, id string, matches
10241026 if err := contextErr(ctx); err != nil {
10251027 return err
10261028 }
1027- if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
1029+ if bucket.Type()&os.ModeSymlink != 0 || !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
10281030 continue
10291031 }
10301032 bucketPath := filepath.Join(dir, bucket.Name())
@@ -1036,13 +1038,17 @@ func collectManagedSessionMatches(ctx context.Context, root, id string, matches
10361038 if err := contextErr(ctx); err != nil {
10371039 return err
10381040 }
1039- if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
1041+ if file.Type()&os.ModeSymlink != 0 || file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
10401042 continue
10411043 }
10421044 path := filepath.Join(bucketPath, file.Name())
1043- meta, err := scanSessionMetaContext(ctx, path)
1044- if err != nil {
1045- return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
1045+ info, err := os.Lstat(path)
1046+ if err != nil || !info.Mode().IsRegular() {
1047+ continue
1048+ }
1049+ meta, err := readSessionMetaHeader(path)
1050+ if err != nil || meta.CWD == "" || filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(bucketPath) {
1051+ continue
10461052 }
10471053 if meta.ID == id {
10481054 *matches = append(*matches, path)
@@ -963,8 +963,10 @@ func FindSessionByID(root, cwd, id string) string {
963 963
964 // FindManagedSessionByID looks up a session UUID across zut's managed964 // FindManagedSessionByID looks up a session UUID across zut's managed
965 // session stores, independent of cwd. A nil error and empty path mean no965 // session stores, independent of cwd. A nil error and empty path mean no
966-// matching session was found. Read, storage, metadata, and duplicate-ID966+// matching session was found. Directory enumeration, cancellation, and
967-// failures are returned to the caller instead of being reported as a miss.967+// duplicate-ID failures are returned to the caller. Candidate files that
968+// cannot be inspected or whose headers are unreadable or invalid are ignored
969+// because they cannot be associated with a requested UUID.
968 func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {970 func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
969 if strings.TrimSpace(root) == "" {971 if strings.TrimSpace(root) == "" {
970 return "", errors.New("find managed session: root is empty")972 return "", errors.New("find managed session: root is empty")
@@ -1024,7 +1026,7 @@ func collectManagedSessionMatches(ctx context.Context, root, id string, matches
1024 if err := contextErr(ctx); err != nil {1026 if err := contextErr(ctx); err != nil {
1025 return err1027 return err
1026 }1028 }
1027- if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {1029+ if bucket.Type()&os.ModeSymlink != 0 || !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
1028 continue1030 continue
1029 }1031 }
1030 bucketPath := filepath.Join(dir, bucket.Name())1032 bucketPath := filepath.Join(dir, bucket.Name())
@@ -1036,13 +1038,17 @@ func collectManagedSessionMatches(ctx context.Context, root, id string, matches
1036 if err := contextErr(ctx); err != nil {1038 if err := contextErr(ctx); err != nil {
1037 return err1039 return err
1038 }1040 }
1039- if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {1041+ if file.Type()&os.ModeSymlink != 0 || file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
1040 continue1042 continue
1041 }1043 }
1042 path := filepath.Join(bucketPath, file.Name())1044 path := filepath.Join(bucketPath, file.Name())
1043- meta, err := scanSessionMetaContext(ctx, path)1045+ info, err := os.Lstat(path)
1044- if err != nil {1046+ if err != nil || !info.Mode().IsRegular() {
1045- return fmt.Errorf("find managed session: read metadata %q: %w", path, err)1047+ continue
1048+ }
1049+ meta, err := readSessionMetaHeader(path)
1050+ if err != nil || meta.CWD == "" || filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(bucketPath) {
1051+ continue
1046 }1052 }
1047 if meta.ID == id {1053 if meta.ID == id {
1048 *matches = append(*matches, path)1054 *matches = append(*matches, path)
M packages/core/session_portable_test.go +150 -6
@@ -12,7 +12,7 @@ import (
1212 "github.com/bnema/zut/packages/provider"
1313 )
1414
15-func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) {
15+func TestFindManagedSessionByIDScopesManagedStoresAndIgnoresUnrelatedCorruption(t *testing.T) {
1616 root := t.TempDir()
1717 cwd := t.TempDir()
1818 session, err := NewSession(root, cwd, "provider", "model", "test")
@@ -59,14 +59,158 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(
5959 t.Fatalf("agent managed session path = %q, want %q", got, agentSession.Path)
6060 }
6161
62+ badMeta, err := json.Marshal(sessionLine{Type: "meta", Meta: &SessionMeta{ID: "unrelated", CWD: cwd}})
63+ if err != nil {
64+ t.Fatal(err)
65+ }
6266 badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
63- if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
67+ if err := os.WriteFile(badPath, append(append(badMeta, '\n'), []byte("not json\n")...), 0o644); err != nil {
68+ t.Fatal(err)
69+ }
70+ got, err = FindManagedSessionByID(context.Background(), root, session.ID)
71+ if err != nil {
72+ t.Fatalf("FindManagedSessionByID with unrelated corrupt session: %v", err)
73+ }
74+ if got != session.Path {
75+ t.Fatalf("managed session path with unrelated corruption = %q, want %q", got, session.Path)
76+ }
77+}
78+
79+func TestFindManagedSessionByIDFindsTargetWithCorruptBody(t *testing.T) {
80+ root := t.TempDir()
81+ cwd := t.TempDir()
82+ session, err := NewSession(root, cwd, "provider", "model", "test")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := session.AppendMessage(provider.Message{
87+ Role: provider.RoleUser,
88+ Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
89+ }); err != nil {
90+ t.Fatal(err)
91+ }
92+ if err := session.Close(); err != nil {
93+ t.Fatal(err)
94+ }
95+
96+ f, err := os.OpenFile(session.Path, os.O_APPEND|os.O_WRONLY, 0o644)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if _, err := f.WriteString("not json\n"); err != nil {
101+ _ = f.Close()
102+ t.Fatal(err)
103+ }
104+ if err := f.Close(); err != nil {
105+ t.Fatal(err)
106+ }
107+
108+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
109+ if err != nil {
110+ t.Fatalf("FindManagedSessionByID: %v", err)
111+ }
112+ if got != session.Path {
113+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
114+ }
115+ if _, _, err := OpenSession(got); err == nil || !strings.Contains(err.Error(), "invalid JSON") {
116+ t.Fatalf("OpenSession corrupt target error = %v, want invalid JSON", err)
117+ }
118+}
119+
120+func TestFindManagedSessionByIDRejectsDuplicateHeaders(t *testing.T) {
121+ root := t.TempDir()
122+ cwd := t.TempDir()
123+ session, err := NewSession(root, cwd, "provider", "model", "test")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := session.AppendMessage(provider.Message{
128+ Role: provider.RoleUser,
129+ Content: []provider.Content{provider.TextBlock{Text: "duplicate me"}},
130+ }); err != nil {
131+ t.Fatal(err)
132+ }
133+ if err := session.Close(); err != nil {
134+ t.Fatal(err)
135+ }
136+ contents, err := os.ReadFile(session.Path)
137+ if err != nil {
64138 t.Fatal(err)
65139 }
66- if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil {
67- t.Fatal("malformed managed metadata was reported as not found")
68- } else if !strings.Contains(err.Error(), "metadata") {
69- t.Fatalf("malformed metadata error = %v, want metadata context", err)
140+ duplicatePath := filepath.Join(SessionsDir(root, cwd), "duplicate.jsonl")
141+ if err := os.WriteFile(duplicatePath, contents, 0o644); err != nil {
142+ t.Fatal(err)
143+ }
144+
145+ if _, err := FindManagedSessionByID(context.Background(), root, session.ID); err == nil || !strings.Contains(err.Error(), "ambiguous") {
146+ t.Fatalf("FindManagedSessionByID duplicate error = %v, want ambiguity", err)
147+ }
148+}
149+
150+func TestFindManagedSessionByIDIgnoresSessionInWrongBucket(t *testing.T) {
151+ root := t.TempDir()
152+ cwd := t.TempDir()
153+ session, err := NewSession(root, cwd, "provider", "model", "test")
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ if err := session.AppendMessage(provider.Message{
158+ Role: provider.RoleUser,
159+ Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
160+ }); err != nil {
161+ t.Fatal(err)
162+ }
163+ if err := session.Close(); err != nil {
164+ t.Fatal(err)
165+ }
166+ contents, err := os.ReadFile(session.Path)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ wrongBucket := filepath.Join(root, "sessions", "0123456789abcdef")
171+ if err := os.MkdirAll(wrongBucket, 0o755); err != nil {
172+ t.Fatal(err)
173+ }
174+ if err := os.WriteFile(filepath.Join(wrongBucket, "misplaced.jsonl"), contents, 0o644); err != nil {
175+ t.Fatal(err)
176+ }
177+
178+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
179+ if err != nil {
180+ t.Fatalf("FindManagedSessionByID: %v", err)
181+ }
182+ if got != session.Path {
183+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
184+ }
185+}
186+
187+func TestFindManagedSessionByIDIgnoresSymlink(t *testing.T) {
188+ root := t.TempDir()
189+ cwd := t.TempDir()
190+ session, err := NewSession(root, cwd, "provider", "model", "test")
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ if err := session.AppendMessage(provider.Message{
195+ Role: provider.RoleUser,
196+ Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
197+ }); err != nil {
198+ t.Fatal(err)
199+ }
200+ if err := session.Close(); err != nil {
201+ t.Fatal(err)
202+ }
203+ symlink := filepath.Join(SessionsDir(root, cwd), "link.jsonl")
204+ if err := os.Symlink(session.Path, symlink); err != nil {
205+ t.Skipf("create session symlink: %v", err)
206+ }
207+
208+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
209+ if err != nil {
210+ t.Fatalf("FindManagedSessionByID: %v", err)
211+ }
212+ if got != session.Path {
213+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
70214 }
71215 }
72216
@@ -12,7 +12,7 @@ import (
12 "github.com/bnema/zut/packages/provider"12 "github.com/bnema/zut/packages/provider"
13 )13 )
14 14
15-func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) {15+func TestFindManagedSessionByIDScopesManagedStoresAndIgnoresUnrelatedCorruption(t *testing.T) {
16 root := t.TempDir()16 root := t.TempDir()
17 cwd := t.TempDir()17 cwd := t.TempDir()
18 session, err := NewSession(root, cwd, "provider", "model", "test")18 session, err := NewSession(root, cwd, "provider", "model", "test")
@@ -59,14 +59,158 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(
59 t.Fatalf("agent managed session path = %q, want %q", got, agentSession.Path)59 t.Fatalf("agent managed session path = %q, want %q", got, agentSession.Path)
60 }60 }
61 61
62+ badMeta, err := json.Marshal(sessionLine{Type: "meta", Meta: &SessionMeta{ID: "unrelated", CWD: cwd}})
63+ if err != nil {
64+ t.Fatal(err)
65+ }
62 badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")66 badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
63- if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {67+ if err := os.WriteFile(badPath, append(append(badMeta, '\n'), []byte("not json\n")...), 0o644); err != nil {
68+ t.Fatal(err)
69+ }
70+ got, err = FindManagedSessionByID(context.Background(), root, session.ID)
71+ if err != nil {
72+ t.Fatalf("FindManagedSessionByID with unrelated corrupt session: %v", err)
73+ }
74+ if got != session.Path {
75+ t.Fatalf("managed session path with unrelated corruption = %q, want %q", got, session.Path)
76+ }
77+}
78+
79+func TestFindManagedSessionByIDFindsTargetWithCorruptBody(t *testing.T) {
80+ root := t.TempDir()
81+ cwd := t.TempDir()
82+ session, err := NewSession(root, cwd, "provider", "model", "test")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := session.AppendMessage(provider.Message{
87+ Role: provider.RoleUser,
88+ Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
89+ }); err != nil {
90+ t.Fatal(err)
91+ }
92+ if err := session.Close(); err != nil {
93+ t.Fatal(err)
94+ }
95+
96+ f, err := os.OpenFile(session.Path, os.O_APPEND|os.O_WRONLY, 0o644)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if _, err := f.WriteString("not json\n"); err != nil {
101+ _ = f.Close()
102+ t.Fatal(err)
103+ }
104+ if err := f.Close(); err != nil {
105+ t.Fatal(err)
106+ }
107+
108+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
109+ if err != nil {
110+ t.Fatalf("FindManagedSessionByID: %v", err)
111+ }
112+ if got != session.Path {
113+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
114+ }
115+ if _, _, err := OpenSession(got); err == nil || !strings.Contains(err.Error(), "invalid JSON") {
116+ t.Fatalf("OpenSession corrupt target error = %v, want invalid JSON", err)
117+ }
118+}
119+
120+func TestFindManagedSessionByIDRejectsDuplicateHeaders(t *testing.T) {
121+ root := t.TempDir()
122+ cwd := t.TempDir()
123+ session, err := NewSession(root, cwd, "provider", "model", "test")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := session.AppendMessage(provider.Message{
128+ Role: provider.RoleUser,
129+ Content: []provider.Content{provider.TextBlock{Text: "duplicate me"}},
130+ }); err != nil {
131+ t.Fatal(err)
132+ }
133+ if err := session.Close(); err != nil {
134+ t.Fatal(err)
135+ }
136+ contents, err := os.ReadFile(session.Path)
137+ if err != nil {
64 t.Fatal(err)138 t.Fatal(err)
65 }139 }
66- if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil {140+ duplicatePath := filepath.Join(SessionsDir(root, cwd), "duplicate.jsonl")
67- t.Fatal("malformed managed metadata was reported as not found")141+ if err := os.WriteFile(duplicatePath, contents, 0o644); err != nil {
68- } else if !strings.Contains(err.Error(), "metadata") {142+ t.Fatal(err)
69- t.Fatalf("malformed metadata error = %v, want metadata context", err)143+ }
144+
145+ if _, err := FindManagedSessionByID(context.Background(), root, session.ID); err == nil || !strings.Contains(err.Error(), "ambiguous") {
146+ t.Fatalf("FindManagedSessionByID duplicate error = %v, want ambiguity", err)
147+ }
148+}
149+
150+func TestFindManagedSessionByIDIgnoresSessionInWrongBucket(t *testing.T) {
151+ root := t.TempDir()
152+ cwd := t.TempDir()
153+ session, err := NewSession(root, cwd, "provider", "model", "test")
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ if err := session.AppendMessage(provider.Message{
158+ Role: provider.RoleUser,
159+ Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
160+ }); err != nil {
161+ t.Fatal(err)
162+ }
163+ if err := session.Close(); err != nil {
164+ t.Fatal(err)
165+ }
166+ contents, err := os.ReadFile(session.Path)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ wrongBucket := filepath.Join(root, "sessions", "0123456789abcdef")
171+ if err := os.MkdirAll(wrongBucket, 0o755); err != nil {
172+ t.Fatal(err)
173+ }
174+ if err := os.WriteFile(filepath.Join(wrongBucket, "misplaced.jsonl"), contents, 0o644); err != nil {
175+ t.Fatal(err)
176+ }
177+
178+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
179+ if err != nil {
180+ t.Fatalf("FindManagedSessionByID: %v", err)
181+ }
182+ if got != session.Path {
183+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
184+ }
185+}
186+
187+func TestFindManagedSessionByIDIgnoresSymlink(t *testing.T) {
188+ root := t.TempDir()
189+ cwd := t.TempDir()
190+ session, err := NewSession(root, cwd, "provider", "model", "test")
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ if err := session.AppendMessage(provider.Message{
195+ Role: provider.RoleUser,
196+ Content: []provider.Content{provider.TextBlock{Text: "keep the session"}},
197+ }); err != nil {
198+ t.Fatal(err)
199+ }
200+ if err := session.Close(); err != nil {
201+ t.Fatal(err)
202+ }
203+ symlink := filepath.Join(SessionsDir(root, cwd), "link.jsonl")
204+ if err := os.Symlink(session.Path, symlink); err != nil {
205+ t.Skipf("create session symlink: %v", err)
206+ }
207+
208+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
209+ if err != nil {
210+ t.Fatalf("FindManagedSessionByID: %v", err)
211+ }
212+ if got != session.Path {
213+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
70 }214 }
71 }215 }
72 216

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close