Files
chenhg5-cc-connect/core/session_id_validation_test.go
cg33 30c7605f95 fix(engine): validate session ID belongs to project before resume (#599) (#1276)
When multiple cc-connect projects share a host, a stored AgentSessionID
can point at a session file that lives in a DIFFERENT project's
~/.claude/projects/<key>/ directory. Resuming it would silently load
the wrong project's conversation history into the current project.

Adds an opt-in SessionIDValidator interface (default-agent helper
included) so the engine asks the agent whether a stored ID is valid
BEFORE calling StartSession. If the agent reports the ID does not
belong to this project, the engine clears the stored ID and starts
fresh — preventing the cross-project context leak.

The ClaudeCode agent implements ValidateSessionID by checking for a
<sessionID>.jsonl file under the per-project directory derived from
the agent's workDir.

Fresh implementation supersedes the stale #604 branch (which mixed
#599 validation with the already-merged #603 duplicate-session prune
CLI). No prune-CLI changes are included here.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-09 23:14:35 +08:00

115 lines
3.8 KiB
Go

package core
import (
"context"
"testing"
)
// validatingAgent wraps a controllableAgent and adds an opt-in
// SessionIDValidator so we can pin the engine's behavior for issue #599:
// when the stored session ID is rejected by the agent, the engine must
// start a fresh session instead of resuming the wrong one.
type validatingAgent struct {
controllableAgent
validateFunc func(ctx context.Context, sessionID string) bool
}
func (a *validatingAgent) ValidateSessionID(ctx context.Context, sessionID string) bool {
if a.validateFunc == nil {
return true // default: trust whatever ID the Session has
}
return a.validateFunc(ctx, sessionID)
}
var _ SessionIDValidator = (*validatingAgent)(nil)
// TestIssue599_InvalidSessionIDClearedBeforeResume pins the regression for
// cross-project session leakage: when the agent rejects the stored
// session ID, the engine must clear the ID and call StartSession with
// "" (fresh start) rather than passing the bad ID through.
func TestIssue599_InvalidSessionIDClearedBeforeResume(t *testing.T) {
var startedWith string
sess := newControllableSession("fresh-id")
agent := &validatingAgent{
controllableAgent: controllableAgent{nextSession: sess},
validateFunc: func(_ context.Context, sessionID string) bool {
// Reject whatever ID the Session carries — simulate a
// cross-project leak.
return false
},
}
agent.startSessionFn = func(_ context.Context, sessionID string) (AgentSession, error) {
startedWith = sessionID
return sess, nil
}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
// Simulate a stored cross-project session ID.
s := &Session{AgentSessionID: "leaked-id-from-other-project"}
e.getOrCreateInteractiveStateWith(key, p, "ctx", s, e.sessions, nil, "")
if startedWith != "" {
t.Errorf("StartSession called with %q, want \"\" (fresh start; leaked id must NOT be passed through)", startedWith)
}
}
// TestIssue599_ValidSessionIDPreserved is the negative case: when the
// agent says the ID is valid, the engine must pass it through to
// StartSession so the resume actually resumes.
func TestIssue599_ValidSessionIDPreserved(t *testing.T) {
var startedWith string
sess := newControllableSession("resumed-id")
agent := &validatingAgent{
controllableAgent: controllableAgent{nextSession: sess},
validateFunc: func(_ context.Context, _ string) bool {
return true
},
}
agent.startSessionFn = func(_ context.Context, sessionID string) (AgentSession, error) {
startedWith = sessionID
return sess, nil
}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
s := &Session{AgentSessionID: "valid-id-abc"}
e.getOrCreateInteractiveStateWith(key, p, "ctx", s, e.sessions, nil, "")
if startedWith != "valid-id-abc" {
t.Errorf("StartSession called with %q, want %q (resume path)", startedWith, "valid-id-abc")
}
}
// TestIssue599_AgentWithoutValidatorNotBlocked ensures the validation
// gate is opt-in: agents that do not implement SessionIDValidator
// continue to work as before (the existing assumption is that engine
// callers only persist valid IDs).
func TestIssue599_AgentWithoutValidatorNotBlocked(t *testing.T) {
var startedWith string
sess := newControllableSession("resumed-id")
agent := &controllableAgent{nextSession: sess}
agent.startSessionFn = func(_ context.Context, sessionID string) (AgentSession, error) {
startedWith = sessionID
return sess, nil
}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
s := &Session{AgentSessionID: "any-id"}
e.getOrCreateInteractiveStateWith(key, p, "ctx", s, e.sessions, nil, "")
if startedWith != "any-id" {
t.Errorf("StartSession called with %q, want %q (no validator = pass through)", startedWith, "any-id")
}
}