mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
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>
115 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|