Skip to content

Commit 706b7d6

Browse files
authored
Merge pull request #54 from luojiyin1987/fix/empty-userid-panic-to-error
fix: convert panic to error log on empty userID in Memory and Session constructors
2 parents 8219b4d + d7c6851 commit 706b7d6

4 files changed

Lines changed: 170 additions & 10 deletions

File tree

internal/agent/memory.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,13 @@ func NewMemory(workspace string) *Memory {
4848
return &Memory{workspace: workspace}
4949
}
5050

51-
// NewMemoryWithStoreForUser is the user-scoped constructor. userID must be
52-
// a real users.id resolved from auth.
51+
// NewMemoryWithStoreForUser is the user-scoped constructor. userID should be
52+
// a real users.id resolved from auth. We keep the Memory alive on empty input
53+
// so a bad request cannot crash the gateway; per-user store reads/writes then
54+
// fail closed until a caller rebinds via WithUserID.
5355
func NewMemoryWithStoreForUser(workspace string, st MemoryStore, userID, agentID string) *Memory {
5456
if userID == "" {
55-
panic("agent.NewMemoryWithStoreForUser: userID is required")
57+
slog.Error("agent.NewMemoryWithStoreForUser: empty userID", "agent", agentID)
5658
}
5759
return &Memory{workspace: workspace, store: st, userID: userID, agentID: agentID}
5860
}
@@ -78,9 +80,8 @@ func (m *Memory) WithUserID(uid string) *Memory {
7880
}
7981

8082
// ctx returns a context tagged with this Memory's user so SQL queries in
81-
// the store layer scope correctly. The store falls back to DefaultUserID
82-
// when no user is on the context, but going through here is explicit and
83-
// keeps callers from accidentally writing under "".
83+
// the store layer scope correctly. Empty userID yields an unscoped ctx;
84+
// store-backed methods guard that case separately and fail closed.
8485
func (m *Memory) ctx() context.Context {
8586
if m.userID == "" {
8687
return context.Background()
@@ -105,6 +106,9 @@ func (m *Memory) historyPath() string {
105106
// only fires on legacy single-user installs without a store.
106107
func (m *Memory) LoadMemory() string {
107108
if m.store != nil {
109+
if m.userID == "" {
110+
return ""
111+
}
108112
content, err := m.store.GetMemory(m.ctx(), m.agentID, m.userID)
109113
if err == nil {
110114
return content
@@ -121,6 +125,9 @@ func (m *Memory) LoadMemory() string {
121125
// SaveMemory overwrites the long-term memory.
122126
func (m *Memory) SaveMemory(content string) error {
123127
if m.store != nil {
128+
if m.userID == "" {
129+
return fmt.Errorf("agent.Memory.SaveMemory: userID required")
130+
}
124131
return m.store.SaveMemory(m.ctx(), m.agentID, m.userID, content)
125132
}
126133
os.MkdirAll(m.workspace, 0o755)
@@ -248,6 +255,9 @@ func (m *Memory) SaveUserFile(content string) error {
248255
}
249256
}
250257
if m.store != nil {
258+
if m.userID == "" {
259+
return fmt.Errorf("agent.Memory.SaveUserFile: userID required")
260+
}
251261
return m.store.SaveWorkspaceFile(m.ctx(), m.agentID, m.userID, "USER.md", []byte(content))
252262
}
253263
os.MkdirAll(m.workspace, 0o755)
@@ -262,6 +272,9 @@ func (m *Memory) SaveUserFile(content string) error {
262272
// workspace copy to a chatter without their own row.
263273
func (m *Memory) LoadUserFile() string {
264274
if m.store != nil {
275+
if m.userID == "" {
276+
return ""
277+
}
265278
data, err := m.store.GetWorkspaceFileExact(m.ctx(), m.agentID, m.userID, "USER.md")
266279
if err == nil {
267280
return string(data)

internal/agent/memory_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
type memoryStoreSpy struct {
9+
getMemoryCalls int
10+
saveMemoryCalls int
11+
getWorkspaceFileExactCall int
12+
saveWorkspaceFileCalls int
13+
}
14+
15+
func (s *memoryStoreSpy) GetMemory(context.Context, string, string) (string, error) {
16+
s.getMemoryCalls++
17+
return "persisted", nil
18+
}
19+
20+
func (s *memoryStoreSpy) SaveMemory(context.Context, string, string, string) error {
21+
s.saveMemoryCalls++
22+
return nil
23+
}
24+
25+
func (s *memoryStoreSpy) GetWorkspaceFile(context.Context, string, string, string) ([]byte, error) {
26+
return nil, nil
27+
}
28+
29+
func (s *memoryStoreSpy) GetWorkspaceFileExact(context.Context, string, string, string) ([]byte, error) {
30+
s.getWorkspaceFileExactCall++
31+
return []byte("user-profile"), nil
32+
}
33+
34+
func (s *memoryStoreSpy) SaveWorkspaceFile(context.Context, string, string, string, []byte) error {
35+
s.saveWorkspaceFileCalls++
36+
return nil
37+
}
38+
39+
func TestNewMemoryWithStoreForUserEmptyUserIDFailsClosed(t *testing.T) {
40+
store := &memoryStoreSpy{}
41+
mem := NewMemoryWithStoreForUser(t.TempDir(), store, "", "agent-1")
42+
if mem == nil {
43+
t.Fatal("expected memory")
44+
}
45+
if got := mem.LoadMemory(); got != "" {
46+
t.Fatalf("LoadMemory() = %q, want empty", got)
47+
}
48+
if store.getMemoryCalls != 0 {
49+
t.Fatalf("GetMemory called %d times, want 0", store.getMemoryCalls)
50+
}
51+
if err := mem.SaveMemory("x"); err == nil {
52+
t.Fatal("SaveMemory() error = nil, want error")
53+
}
54+
if store.saveMemoryCalls != 0 {
55+
t.Fatalf("SaveMemory store calls = %d, want 0", store.saveMemoryCalls)
56+
}
57+
if got := mem.LoadUserFile(); got != "" {
58+
t.Fatalf("LoadUserFile() = %q, want empty", got)
59+
}
60+
if store.getWorkspaceFileExactCall != 0 {
61+
t.Fatalf("GetWorkspaceFileExact called %d times, want 0", store.getWorkspaceFileExactCall)
62+
}
63+
if err := mem.SaveUserFile("x"); err == nil {
64+
t.Fatal("SaveUserFile() error = nil, want error")
65+
}
66+
if store.saveWorkspaceFileCalls != 0 {
67+
t.Fatalf("SaveWorkspaceFile calls = %d, want 0", store.saveWorkspaceFileCalls)
68+
}
69+
}
70+
71+
func TestNewMemoryWithStoreForUserCanBeRebound(t *testing.T) {
72+
store := &memoryStoreSpy{}
73+
mem := NewMemoryWithStoreForUser(t.TempDir(), store, "", "agent-1").WithUserID("user-1")
74+
if got := mem.LoadMemory(); got != "persisted" {
75+
t.Fatalf("LoadMemory() = %q, want persisted", got)
76+
}
77+
if err := mem.SaveMemory("x"); err != nil {
78+
t.Fatalf("SaveMemory() error = %v", err)
79+
}
80+
if got := mem.LoadUserFile(); got != "user-profile" {
81+
t.Fatalf("LoadUserFile() = %q, want user-profile", got)
82+
}
83+
if err := mem.SaveUserFile("x"); err != nil {
84+
t.Fatalf("SaveUserFile() error = %v", err)
85+
}
86+
}

internal/session/manager.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func (s *Session) SessionKey() string { return s.sessionKey }
7575
// session_events.chatter_user_id) can record the actual conversation
7676
// participant. user_id stays = UserSpace owner; chatter is the
7777
// additional dimension. Both tags are independent — empty chatter
78-
// just leaves the column ''.
78+
// just leaves the column .
7979
func (s *Session) ctx() context.Context {
8080
ctx := context.Background()
8181
if s.userID != "" {
@@ -91,7 +91,7 @@ func (s *Session) ctx() context.Context {
9191
// Session so the next Append / SaveSession write stamps the
9292
// chatter_user_id column. Called by the agent loop at the top of each
9393
// turn from the resolved chatterUID. Passing "" clears it (the next
94-
// write goes back to '' which readers fall back to user_id for).
94+
// write goes back to which readers fall back to user_id for).
9595
func (s *Session) SetChatter(uid string) {
9696
s.mu.Lock()
9797
s.chatterUserID = uid
@@ -158,10 +158,12 @@ func NewManager(dataDir string) *Manager {
158158
}
159159

160160
// NewManagerWithStoreForUser is the user-scoped constructor. Caller MUST
161-
// supply a real user_id resolved from auth — there is no fallback.
161+
// supply a real user_id resolved from auth. We log and keep the Manager
162+
// alive on empty input so a bad request cannot crash the whole gateway;
163+
// downstream store calls will fail closed under the empty owner.
162164
func NewManagerWithStoreForUser(dataDir string, st SessionStore, userID, agentID string) *Manager {
163165
if userID == "" {
164-
panic("session.NewManagerWithStoreForUser: userID is required")
166+
fmt.Fprintf(os.Stderr, "session.NewManagerWithStoreForUser: empty userID for agent %q\n", agentID)
165167
}
166168
return &Manager{
167169
sessions: make(map[string]*Session),

internal/session/manager_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package session
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/fastclaw-ai/fastclaw/internal/provider"
8+
)
9+
10+
type noopSessionStore struct{}
11+
12+
func (noopSessionStore) GetSession(context.Context, string, string) ([]provider.Message, error) {
13+
return nil, nil
14+
}
15+
func (noopSessionStore) SaveSession(context.Context, string, string, string, string, string, string, []provider.Message) error {
16+
return nil
17+
}
18+
func (noopSessionStore) AppendMessage(context.Context, string, string, provider.Message) error {
19+
return nil
20+
}
21+
func (noopSessionStore) ListMessages(context.Context, string, string) ([]provider.Message, error) {
22+
return nil, nil
23+
}
24+
func (noopSessionStore) ListWebSessions(context.Context, string) ([]WebSession, error) {
25+
return nil, nil
26+
}
27+
func (noopSessionStore) DeleteSession(context.Context, string, string) error {
28+
return nil
29+
}
30+
func (noopSessionStore) RenameSession(context.Context, string, string, string) error {
31+
return nil
32+
}
33+
func (noopSessionStore) MoveSession(context.Context, string, string, string) error {
34+
return nil
35+
}
36+
func (noopSessionStore) ResolveActiveSessionKey(context.Context, string, string, string, string) (string, error) {
37+
return "", nil
38+
}
39+
func (noopSessionStore) LookupSessionTriple(context.Context, string, string) (string, string, string, error) {
40+
return "", "", "", nil
41+
}
42+
func (noopSessionStore) LookupSessionProject(context.Context, string, string) (string, error) {
43+
return "", nil
44+
}
45+
46+
func TestNewManagerWithStoreForUserEmptyUserIDDoesNotPanic(t *testing.T) {
47+
mgr := NewManagerWithStoreForUser(t.TempDir(), noopSessionStore{}, "", "agent-1")
48+
if mgr == nil {
49+
t.Fatal("expected manager")
50+
}
51+
s := mgr.Get("web", "", "chat-1", "")
52+
if s == nil {
53+
t.Fatal("expected session")
54+
}
55+
s.Append(provider.Message{Role: "user", Content: "hello"})
56+
if got := len(s.GetMessages()); got != 1 {
57+
t.Fatalf("message count = %d, want 1", got)
58+
}
59+
}

0 commit comments

Comments
 (0)