diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index 90896952..f0245a73 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -91,6 +91,12 @@ var userSeedFilesPredefined = []string{ // For "open" agents: all 7 files (including BOOTSTRAP.md). // For "predefined" agents: USER.md + BOOTSTRAP.md (user-focused onboarding template). // Only writes files that don't already exist — safe to call multiple times. +// +// For predefined agents seeding USER.md: if the agent already has a populated +// USER.md in agent_context_files (e.g. written by the wizard or management dashboard), +// that content is used as the per-user seed instead of the blank embedded template. +// This ensures wizard-configured owner profiles are preserved on first chat. +// // Returns the list of file names that were seeded. func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) { files := userSeedFilesOpen @@ -98,7 +104,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui files = userSeedFilesPredefined } - // Check existing files to avoid overwriting personalized content + // Check existing per-user files to avoid overwriting personalized content existing, err := agentStore.GetUserContextFiles(ctx, agentID, userID) if err != nil { return nil, err @@ -110,10 +116,40 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui } } + // For predefined agents: load agent-level files once to use as seed fallback. + // USER.md at agent-level may contain a pre-configured owner profile (e.g. set by + // the wizard or management dashboard). Use it as the per-user seed instead of the + // blank embedded template so the agent starts with the correct owner context. + var agentLevelFiles map[string]string + if agentType == store.AgentTypePredefined { + agentFiles, err := agentStore.GetAgentContextFiles(ctx, agentID) + if err == nil && len(agentFiles) > 0 { + agentLevelFiles = make(map[string]string, len(agentFiles)) + for _, f := range agentFiles { + if f.Content != "" { + agentLevelFiles[f.FileName] = f.Content + } + } + } + } + var seeded []string for _, name := range files { if hasFile[name] { - continue // already has content, don't overwrite + continue // already has personalized content, don't overwrite + } + + // For predefined agents seeding USER.md: prefer agent-level content as seed. + // This propagates wizard/dashboard-configured owner profile to the first user. + if agentType == store.AgentTypePredefined && name == UserFile { + if agentContent, ok := agentLevelFiles[name]; ok { + if err := agentStore.SetUserContextFile(ctx, agentID, userID, name, agentContent); err != nil { + return seeded, err + } + seeded = append(seeded, name) + continue + } + // No agent-level USER.md → fall through to blank embedded template } // Predefined agents use a user-focused bootstrap template diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go new file mode 100644 index 00000000..33b4bc3b --- /dev/null +++ b/internal/bootstrap/seed_store_test.go @@ -0,0 +1,279 @@ +package bootstrap + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ---- minimal AgentStore stub for seed tests ---- + +type seedStubStore struct { + // agent-level files (simulates agent_context_files) + agentFiles map[string]string // fileName → content + // per-user files (simulates user_context_files) + userFiles map[string]string // fileName → content (shared across all users for simplicity) + // captures what was written per-user: fileName → content + seededUserFiles map[string]string +} + +func newSeedStub() *seedStubStore { + return &seedStubStore{ + agentFiles: make(map[string]string), + userFiles: make(map[string]string), + seededUserFiles: make(map[string]string), + } +} + +func (s *seedStubStore) GetAgentContextFiles(_ context.Context, _ uuid.UUID) ([]store.AgentContextFileData, error) { + var out []store.AgentContextFileData + for name, content := range s.agentFiles { + out = append(out, store.AgentContextFileData{FileName: name, Content: content}) + } + return out, nil +} +func (s *seedStubStore) SetAgentContextFile(_ context.Context, _ uuid.UUID, name, content string) error { + s.agentFiles[name] = content + return nil +} +func (s *seedStubStore) GetUserContextFiles(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) { + var out []store.UserContextFileData + for name, content := range s.userFiles { + out = append(out, store.UserContextFileData{FileName: name, Content: content}) + } + return out, nil +} +func (s *seedStubStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, name, content string) error { + s.seededUserFiles[name] = content + return nil +} +func (s *seedStubStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { + return nil +} + +// Remaining interface methods — not exercised. +func (s *seedStubStore) Create(_ context.Context, _ *store.AgentData) error { return nil } +func (s *seedStubStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { return nil, nil } +func (s *seedStubStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *seedStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } +func (s *seedStubStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } +func (s *seedStubStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil } +func (s *seedStubStore) ShareAgent(_ context.Context, _ uuid.UUID, _, _, _ string) error { return nil } +func (s *seedStubStore) RevokeShare(_ context.Context, _ uuid.UUID, _ string) error { return nil } +func (s *seedStubStore) ListShares(_ context.Context, _ uuid.UUID) ([]store.AgentShareData, error) { + return nil, nil +} +func (s *seedStubStore) CanAccess(_ context.Context, _ uuid.UUID, _ string) (bool, string, error) { + return true, "admin", nil +} +func (s *seedStubStore) ListAccessible(_ context.Context, _ string) ([]store.AgentData, error) { + return nil, nil +} +func (s *seedStubStore) GetUserOverride(_ context.Context, _ uuid.UUID, _ string) (*store.UserAgentOverrideData, error) { + return nil, nil +} +func (s *seedStubStore) SetUserOverride(_ context.Context, _ *store.UserAgentOverrideData) error { + return nil +} +func (s *seedStubStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) { + return false, nil +} +func (s *seedStubStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) { + return false, nil +} +func (s *seedStubStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error { + return nil +} +func (s *seedStubStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error { + return nil +} +func (s *seedStubStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) { + return nil, nil +} + +// ---- Tests ---- + +// TestSeedUserFiles_PredefinedAgent_UsesAgentLevelUserMD is the primary regression test. +// When a predefined agent has wizard-written USER.md in agent_context_files, SeedUserFiles +// must seed that content into user_context_files — NOT the blank embedded template. +func TestSeedUserFiles_PredefinedAgent_UsesAgentLevelUserMD(t *testing.T) { + as := newSeedStub() + agentID := uuid.New() + wizardContent := "# User Profile\nOwner: Alice\nLanguage: English\nNotes: Prefers concise answers" + + // Simulate wizard writing USER.md at agent level via agents.files.set + as.agentFiles[UserFile] = wizardContent + + seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-alice", store.AgentTypePredefined) + if err != nil { + t.Fatalf("SeedUserFiles returned error: %v", err) + } + + // USER.md must be in the seeded list + foundUserMD := false + for _, f := range seeded { + if f == UserFile { + foundUserMD = true + } + } + if !foundUserMD { + t.Errorf("USER.md not in seeded files list: %v", seeded) + } + + // The seeded USER.md must contain wizard content, not the blank template + got, ok := as.seededUserFiles[UserFile] + if !ok { + t.Fatal("USER.md was not written to user_context_files") + } + if got != wizardContent { + t.Errorf("seeded USER.md content mismatch:\n want: %q\n got: %q", wizardContent, got) + } +} + +// TestSeedUserFiles_PredefinedAgent_FallsBackToTemplateWhenNoAgentLevelUserMD verifies +// that when there is no wizard-written USER.md at agent level, the blank template is used. +func TestSeedUserFiles_PredefinedAgent_FallsBackToTemplateWhenNoAgentLevelUserMD(t *testing.T) { + as := newSeedStub() + agentID := uuid.New() + // No agent-level USER.md — wizard did not write one + + seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-bob", store.AgentTypePredefined) + if err != nil { + t.Fatalf("SeedUserFiles returned error: %v", err) + } + + // USER.md should still be seeded (from embedded template) + foundUserMD := false + for _, f := range seeded { + if f == UserFile { + foundUserMD = true + } + } + if !foundUserMD { + t.Errorf("USER.md should be seeded from template when no agent-level file exists: %v", seeded) + } + + // Content should be the embedded template (non-empty — the template file exists) + got, ok := as.seededUserFiles[UserFile] + if !ok { + t.Fatal("USER.md was not written to user_context_files") + } + if got == "" { + t.Error("seeded USER.md should not be empty (expected embedded template content)") + } +} + +// TestSeedUserFiles_PredefinedAgent_DoesNotOverwriteExistingPerUserContent verifies +// that personalized per-user USER.md written via conversation is never overwritten. +func TestSeedUserFiles_PredefinedAgent_DoesNotOverwriteExistingPerUserContent(t *testing.T) { + as := newSeedStub() + agentID := uuid.New() + personalContent := "# User Profile\nMy customized personal content" + + // Pre-populate per-user USER.md (simulates user who already chatted and personalized) + as.userFiles[UserFile] = personalContent + // Also set wizard content at agent level + as.agentFiles[UserFile] = "wizard content that should NOT override personal content" + + seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-charlie", store.AgentTypePredefined) + if err != nil { + t.Fatalf("SeedUserFiles returned error: %v", err) + } + + // USER.md must NOT be in the seeded list (existing content, should skip) + for _, f := range seeded { + if f == UserFile { + t.Error("USER.md should NOT be re-seeded when per-user content already exists") + } + } + + // SetUserContextFile must not have been called for USER.md + if _, wrote := as.seededUserFiles[UserFile]; wrote { + t.Error("SetUserContextFile should not be called when per-user USER.md already has content") + } +} + +// TestSeedUserFiles_OpenAgent_UsesEmbeddedTemplate verifies that open agents +// are completely unaffected — they still receive embedded templates per-user. +func TestSeedUserFiles_OpenAgent_UsesEmbeddedTemplate(t *testing.T) { + as := newSeedStub() + agentID := uuid.New() + // Open agents should never check agent_context_files for USER.md + + seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-dave", store.AgentTypeOpen) + if err != nil { + t.Fatalf("SeedUserFiles returned error: %v", err) + } + + // Open agents seed the full set: AGENTS.md, SOUL.md, IDENTITY.md, USER.md, BOOTSTRAP.md + expectedFiles := map[string]bool{ + AgentsFile: true, SoulFile: true, IdentityFile: true, UserFile: true, BootstrapFile: true, + } + for _, f := range seeded { + delete(expectedFiles, f) + } + if len(expectedFiles) > 0 { + t.Errorf("open agent: missing seeded files: %v", expectedFiles) + } + + // USER.md must have been written using embedded template (non-empty) + got, ok := as.seededUserFiles[UserFile] + if !ok { + t.Fatal("open agent: USER.md was not written to user_context_files") + } + if got == "" { + t.Error("open agent: seeded USER.md should not be empty") + } +} + +// TestSeedUserFiles_PredefinedAgent_BootstrapUsesCorrectTemplate verifies that +// BOOTSTRAP.md is still seeded from BOOTSTRAP_PREDEFINED.md for predefined agents. +func TestSeedUserFiles_PredefinedAgent_BootstrapUsesCorrectTemplate(t *testing.T) { + as := newSeedStub() + agentID := uuid.New() + + _, err := SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined) + if err != nil { + t.Fatalf("SeedUserFiles returned error: %v", err) + } + + // BOOTSTRAP.md must have been seeded + got, ok := as.seededUserFiles[BootstrapFile] + if !ok { + t.Fatal("predefined agent: BOOTSTRAP.md was not seeded") + } + if got == "" { + t.Error("predefined agent: BOOTSTRAP.md should have content from BOOTSTRAP_PREDEFINED.md template") + } +} + +// TestSeedUserFiles_IdempotentOnSecondCall verifies that calling SeedUserFiles +// a second time for the same user does not re-seed already-present files. +func TestSeedUserFiles_IdempotentOnSecondCall(t *testing.T) { + as := newSeedStub() + agentID := uuid.New() + + // First call — seeds files + SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined) + + // Simulate what the first call wrote (move seededUserFiles → userFiles) + for k, v := range as.seededUserFiles { + as.userFiles[k] = v + } + as.seededUserFiles = make(map[string]string) + + // Second call — must seed nothing (all files already exist) + seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined) + if err != nil { + t.Fatalf("second SeedUserFiles returned error: %v", err) + } + if len(seeded) != 0 { + t.Errorf("second call should seed nothing, but seeded: %v", seeded) + } + if len(as.seededUserFiles) != 0 { + t.Errorf("second call should not write any files, but wrote: %v", as.seededUserFiles) + } +} diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index 2edf34da..d69e323b 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -103,11 +103,12 @@ func (m *AgentsMethods) handleList(_ context.Context, client *gateway.Client, re func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { - Name string `json:"name"` - Workspace string `json:"workspace"` - Emoji string `json:"emoji"` - Avatar string `json:"avatar"` - AgentType string `json:"agent_type"` // "open" (default) or "predefined" + Name string `json:"name"` + Workspace string `json:"workspace"` + Emoji string `json:"emoji"` + Avatar string `json:"avatar"` + AgentType string `json:"agent_type"` // "open" (default) or "predefined" + OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system" // Per-agent config overrides (managed mode only) ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` @@ -155,10 +156,17 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, return } + // Resolve owner: use first provided ID so external provisioning tools (e.g. goclaw-wizards) + // can set a real user as owner at creation time. Falls back to "system" for backward compat. + ownerID := "system" + if len(params.OwnerIDs) > 0 && params.OwnerIDs[0] != "" { + ownerID = params.OwnerIDs[0] + } + agentData := &store.AgentData{ AgentKey: agentID, DisplayName: params.Name, - OwnerID: "system", + OwnerID: ownerID, AgentType: agentType, Provider: m.cfg.Agents.Defaults.Provider, Model: m.cfg.Agents.Defaults.Model, diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go new file mode 100644 index 00000000..f8d5621d --- /dev/null +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -0,0 +1,221 @@ +package methods + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// ---- stub AgentStore that captures Create calls ---- + +type createCaptureStore struct { + created *store.AgentData +} + +func (s *createCaptureStore) Create(_ context.Context, d *store.AgentData) error { + d.ID = uuid.New() // simulate DB-assigned UUID so SeedToStore doesn't panic + s.created = d + return nil +} +func (s *createCaptureStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { + return nil, nil // agent does not exist yet → allows creation to proceed +} +func (s *createCaptureStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { + return nil, nil +} +func (s *createCaptureStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { + return nil +} +func (s *createCaptureStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } +func (s *createCaptureStore) List(_ context.Context, _ string) ([]store.AgentData, error) { + return nil, nil +} +func (s *createCaptureStore) ShareAgent(_ context.Context, _ uuid.UUID, _, _, _ string) error { + return nil +} +func (s *createCaptureStore) RevokeShare(_ context.Context, _ uuid.UUID, _ string) error { + return nil +} +func (s *createCaptureStore) ListShares(_ context.Context, _ uuid.UUID) ([]store.AgentShareData, error) { + return nil, nil +} +func (s *createCaptureStore) CanAccess(_ context.Context, _ uuid.UUID, _ string) (bool, string, error) { + return true, "owner", nil +} +func (s *createCaptureStore) ListAccessible(_ context.Context, _ string) ([]store.AgentData, error) { + return nil, nil +} +func (s *createCaptureStore) GetAgentContextFiles(_ context.Context, _ uuid.UUID) ([]store.AgentContextFileData, error) { + return nil, nil +} +func (s *createCaptureStore) SetAgentContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { + return nil +} +func (s *createCaptureStore) GetUserContextFiles(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) { + return nil, nil +} +func (s *createCaptureStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, _, _ string) error { + return nil +} +func (s *createCaptureStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { + return nil +} +func (s *createCaptureStore) GetUserOverride(_ context.Context, _ uuid.UUID, _ string) (*store.UserAgentOverrideData, error) { + return nil, nil +} +func (s *createCaptureStore) SetUserOverride(_ context.Context, _ *store.UserAgentOverrideData) error { + return nil +} +func (s *createCaptureStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) { + return false, nil +} +func (s *createCaptureStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) { + return false, nil +} +func (s *createCaptureStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error { + return nil +} +func (s *createCaptureStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error { + return nil +} +func (s *createCaptureStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) { + return nil, nil +} + +// ---- helpers ---- + +// minimalConfig returns a config sufficient for handleCreate (provider + model defaults only). +func minimalConfig() *config.Config { + cfg := &config.Config{} + cfg.Agents.Defaults.Provider = "anthropic" + cfg.Agents.Defaults.Model = "claude-3-5-haiku-20241022" + return cfg +} + +// buildCreateRequest marshals params into a RequestFrame for agents.create. +func buildCreateRequest(t *testing.T, params map[string]any) *protocol.RequestFrame { + t.Helper() + raw, err := json.Marshal(params) + if err != nil { + t.Fatalf("marshal params: %v", err) + } + return &protocol.RequestFrame{ + ID: "test-req-1", + Method: protocol.MethodAgentsCreate, + Params: raw, + } +} + +// nullClient returns a zero-value Client. Its send channel is nil, so SendResponse +// safely falls to the select default branch — no panic, response silently dropped. +func nullClient() *gateway.Client { + return &gateway.Client{} +} + +// newManagedMethods returns AgentsMethods wired for managed mode with the given stub store. +func newManagedMethods(t *testing.T, stub store.AgentStore) *AgentsMethods { + t.Helper() + return &AgentsMethods{ + agents: agent.NewRouter(), + cfg: minimalConfig(), + workspace: t.TempDir(), + agentStore: stub, + isManaged: true, + } +} + +// ---- Tests ---- + +// TestHandleCreate_UsesProvidedOwnerID verifies that when owner_ids is supplied, +// the first entry is used as the agent's owner_id in the DB — not "system". +func TestHandleCreate_UsesProvidedOwnerID(t *testing.T) { + stub := &createCaptureStore{} + m := newManagedMethods(t, stub) + + req := buildCreateRequest(t, map[string]any{ + "name": "Test Agent", + "agent_type": "predefined", + "owner_ids": []string{"8514594032"}, + }) + + m.handleCreate(context.Background(), nullClient(), req) + + if stub.created == nil { + t.Fatal("agentStore.Create was not called") + } + if stub.created.OwnerID != "8514594032" { + t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "8514594032") + } +} + +// TestHandleCreate_FallsBackToSystem_WhenOwnerIDsAbsent verifies that omitting +// owner_ids preserves backward compat — agent is created with owner_id = "system". +func TestHandleCreate_FallsBackToSystem_WhenOwnerIDsAbsent(t *testing.T) { + stub := &createCaptureStore{} + m := newManagedMethods(t, stub) + + req := buildCreateRequest(t, map[string]any{ + "name": "Test Agent Two", + "agent_type": "predefined", + // owner_ids intentionally absent + }) + + m.handleCreate(context.Background(), nullClient(), req) + + if stub.created == nil { + t.Fatal("agentStore.Create was not called") + } + if stub.created.OwnerID != "system" { + t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "system") + } +} + +// TestHandleCreate_FallsBackToSystem_WhenOwnerIDsEmpty verifies that an empty +// owner_ids slice also falls back to "system". +func TestHandleCreate_FallsBackToSystem_WhenOwnerIDsEmpty(t *testing.T) { + stub := &createCaptureStore{} + m := newManagedMethods(t, stub) + + req := buildCreateRequest(t, map[string]any{ + "name": "Test Agent Three", + "owner_ids": []string{}, + }) + + m.handleCreate(context.Background(), nullClient(), req) + + if stub.created == nil { + t.Fatal("agentStore.Create was not called") + } + if stub.created.OwnerID != "system" { + t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "system") + } +} + +// TestHandleCreate_MultipleOwnerIDs_UsesFirst verifies that when multiple IDs +// are supplied, only the first entry is used as owner_id. +func TestHandleCreate_MultipleOwnerIDs_UsesFirst(t *testing.T) { + stub := &createCaptureStore{} + m := newManagedMethods(t, stub) + + req := buildCreateRequest(t, map[string]any{ + "name": "Test Agent Four", + "owner_ids": []string{"first-owner", "second-owner"}, + }) + + m.handleCreate(context.Background(), nullClient(), req) + + if stub.created == nil { + t.Fatal("agentStore.Create was not called") + } + if stub.created.OwnerID != "first-owner" { + t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "first-owner") + } +} diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 39babb46..ec7a2fa1 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -283,7 +283,22 @@ func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]sto rows, err := s.db.QueryContext(ctx, `SELECT `+agentSelectCols+` FROM agents - WHERE deleted_at IS NULL AND (owner_id = $1 OR is_default = true OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1)) + WHERE deleted_at IS NULL AND ( + owner_id = $1 + OR is_default = true + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1) + OR ( + agent_type = 'predefined' + AND id IN ( + SELECT agent_id FROM channel_instances ci + WHERE ci.enabled = true + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af + WHERE af = $1 + ) + ) + ) + ) ORDER BY created_at DESC`, userID) if err != nil { return nil, err