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 Unverified
B5690EEEBB952194The 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 | |||
| 3007 | 3007 | } | |
| 3008 | 3008 | // Sweep meta-only files left over from older zut versions (and from | |
| 3009 | 3009 | // 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. | |
| 3012 | 3013 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | |
| 3013 | 3014 | if args.ResumeSessionID == "" { | |
| 3014 | 3015 | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| @@ -3042,6 +3043,13 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag | |||
| 3042 | 3043 | return nil, fmt.Errorf("session %q not found", args.ResumeSessionID) | |
| 3043 | 3044 | } | |
| 3044 | 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 | 3053 | break | |
| 3046 | 3054 | } | |
| 3047 | 3055 | 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 from | 3008 | // Sweep meta-only files left over from older zut versions (and from | ||
| 3009 | // any session that crashed before its first AppendMessage) for the | 3009 | // any session that crashed before its first AppendMessage) for the | ||
| 3010 | - | // cwd-scoped picker paths. Explicit UUID lookup stays strict so it | 3010 | + | // 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 | break | 3053 | 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) { | |||
| 555 | 555 | } | |
| 556 | 556 | } | |
| 557 | 557 | ||
| 558 | - | func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) { | |
| 558 | + | func TestOpenOrCreateSessionUUIDLookupIgnoresUnrelatedCorruption(t *testing.T) { | |
| 559 | 559 | root := t.TempDir() | |
| 560 | 560 | cwd := t.TempDir() | |
| 561 | 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 | 573 | t.Fatal(err) | |
| 564 | 574 | } | |
| 565 | 575 | if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil { | |
| 566 | 576 | t.Fatal(err) | |
| 567 | 577 | } | |
| 568 | 578 | ||
| 569 | - | _, err := openOrCreateSession(context.Background(), Args{ | |
| 579 | + | session, err := openOrCreateSession(context.Background(), Args{ | |
| 570 | 580 | CWD: cwd, | |
| 571 | 581 | Resume: true, | |
| 572 | - | ResumeSessionID: uuid.NewString(), | |
| 582 | + | ResumeSessionID: target.ID, | |
| 573 | 583 | }, 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) | |
| 576 | 586 | } | |
| 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) | |
| 579 | 590 | } | |
| 580 | 591 | } | |
| 581 | 592 | ||
| @@ -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 | |||
| 27 | 27 | if err != nil { | |
| 28 | 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 | 37 | defer sess.Close() | |
| 31 | 38 | ||
| 32 | 39 | 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 { | |||
| 29 | 29 | // content hydration. Callers only implement projection-specific reduction. | |
| 30 | 30 | func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessionLogRow) error) error { | |
| 31 | 31 | sawMeta := false | |
| 32 | + | sessionID := "" | |
| 32 | 33 | err := forEachStrictJSONLLineContext(ctx, r, func(line []byte, lineNo int) error { | |
| 33 | 34 | row, err := decodeSessionLogRow(line) | |
| 34 | 35 | if err != nil { | |
| @@ -38,6 +39,10 @@ func forEachSessionLogRowContext(ctx context.Context, r io.Reader, fn func(sessi | |||
| 38 | 39 | return fmt.Errorf("line %d: first row is not meta", lineNo) | |
| 39 | 40 | } | |
| 40 | 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 | 46 | sawMeta = true | |
| 42 | 47 | } | |
| 43 | 48 | 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 := false | 31 | 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 = true | 46 | 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) { | |||
| 28 | 28 | {name: "invalid usage", contents: string(metaLine) + "\n" + `{"type":"usage","cumulative":null}` + "\n", want: "usage row has no cumulative usage"}, | |
| 29 | 29 | {name: "invalid rename", contents: string(metaLine) + "\n" + `{"type":"rename"}` + "\n", want: "rename row has no title"}, | |
| 30 | 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 | 34 | 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 { | |||
| 963 | 963 | ||
| 964 | 964 | // FindManagedSessionByID looks up a session UUID across zut's managed | |
| 965 | 965 | // 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. | |
| 968 | 970 | func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) { | |
| 969 | 971 | if strings.TrimSpace(root) == "" { | |
| 970 | 972 | return "", errors.New("find managed session: root is empty") | |
| @@ -1024,7 +1026,7 @@ func collectManagedSessionMatches(ctx context.Context, root, id string, matches | |||
| 1024 | 1026 | if err := contextErr(ctx); err != nil { | |
| 1025 | 1027 | 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 | 1030 | continue | |
| 1029 | 1031 | } | |
| 1030 | 1032 | bucketPath := filepath.Join(dir, bucket.Name()) | |
| @@ -1036,13 +1038,17 @@ func collectManagedSessionMatches(ctx context.Context, root, id string, matches | |||
| 1036 | 1038 | if err := contextErr(ctx); err != nil { | |
| 1037 | 1039 | 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 | 1042 | continue | |
| 1041 | 1043 | } | |
| 1042 | 1044 | 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 | |
| 1046 | 1052 | } | |
| 1047 | 1053 | if meta.ID == id { | |
| 1048 | 1054 | *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 managed | 964 | // FindManagedSessionByID looks up a session UUID across zut's managed | ||
| 965 | // session stores, independent of cwd. A nil error and empty path mean no | 965 | // session stores, independent of cwd. A nil error and empty path mean no | ||
| 966 | - | // matching session was found. Read, storage, metadata, and duplicate-ID | 966 | + | // 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 err | 1027 | 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 | continue | 1030 | 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 err | 1039 | 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 | continue | 1042 | 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 ( | |||
| 12 | 12 | "github.com/bnema/zut/packages/provider" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) { | |
| 15 | + | func TestFindManagedSessionByIDScopesManagedStoresAndIgnoresUnrelatedCorruption(t *testing.T) { | |
| 16 | 16 | root := t.TempDir() | |
| 17 | 17 | cwd := t.TempDir() | |
| 18 | 18 | session, err := NewSession(root, cwd, "provider", "model", "test") | |
| @@ -59,14 +59,158 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors( | |||
| 59 | 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 | 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 | 138 | t.Fatal(err) | |
| 65 | 139 | } | |
| 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) | |
| 70 | 214 | } | |
| 71 | 215 | } | |
| 72 | 216 | ||
| @@ -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 | ||||